@colletdev/core 0.1.3
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 +77 -0
- package/custom-elements.json +6037 -0
- package/generated/.gitattributes +2 -0
- package/generated/index.d.ts +120 -0
- package/generated/index.js +521 -0
- package/generated/styles.js +2845 -0
- package/package.json +56 -0
- package/src/elements/accordion.d.ts +20 -0
- package/src/elements/accordion.js +92 -0
- package/src/elements/activity_group.d.ts +19 -0
- package/src/elements/activity_group.js +27 -0
- package/src/elements/alert.d.ts +24 -0
- package/src/elements/alert.js +40 -0
- package/src/elements/autocomplete.d.ts +30 -0
- package/src/elements/autocomplete.js +671 -0
- package/src/elements/avatar.d.ts +18 -0
- package/src/elements/avatar.js +28 -0
- package/src/elements/backdrop.d.ts +14 -0
- package/src/elements/backdrop.js +28 -0
- package/src/elements/badge.d.ts +21 -0
- package/src/elements/badge.js +42 -0
- package/src/elements/breadcrumb.d.ts +17 -0
- package/src/elements/breadcrumb.js +41 -0
- package/src/elements/button.d.ts +24 -0
- package/src/elements/button.js +36 -0
- package/src/elements/card.d.ts +21 -0
- package/src/elements/card.js +67 -0
- package/src/elements/carousel.d.ts +23 -0
- package/src/elements/carousel.js +895 -0
- package/src/elements/chat_input.d.ts +22 -0
- package/src/elements/chat_input.js +78 -0
- package/src/elements/checkbox.d.ts +21 -0
- package/src/elements/checkbox.js +114 -0
- package/src/elements/code_block.d.ts +21 -0
- package/src/elements/code_block.js +27 -0
- package/src/elements/collapsible.d.ts +20 -0
- package/src/elements/collapsible.js +93 -0
- package/src/elements/date_picker.d.ts +30 -0
- package/src/elements/date_picker.js +528 -0
- package/src/elements/dialog.d.ts +20 -0
- package/src/elements/dialog.js +314 -0
- package/src/elements/drawer.d.ts +20 -0
- package/src/elements/drawer.js +318 -0
- package/src/elements/fab.d.ts +22 -0
- package/src/elements/fab.js +36 -0
- package/src/elements/file_upload.d.ts +26 -0
- package/src/elements/file_upload.js +59 -0
- package/src/elements/listbox.d.ts +19 -0
- package/src/elements/listbox.js +250 -0
- package/src/elements/menu.d.ts +20 -0
- package/src/elements/menu.js +224 -0
- package/src/elements/message_bubble.d.ts +23 -0
- package/src/elements/message_bubble.js +29 -0
- package/src/elements/message_group.d.ts +18 -0
- package/src/elements/message_group.js +28 -0
- package/src/elements/message_part.d.ts +35 -0
- package/src/elements/message_part.js +153 -0
- package/src/elements/pagination.d.ts +22 -0
- package/src/elements/pagination.js +36 -0
- package/src/elements/popover.d.ts +26 -0
- package/src/elements/popover.js +191 -0
- package/src/elements/profile_menu.d.ts +20 -0
- package/src/elements/profile_menu.js +213 -0
- package/src/elements/progress.d.ts +18 -0
- package/src/elements/progress.js +31 -0
- package/src/elements/radio_group.d.ts +22 -0
- package/src/elements/radio_group.js +70 -0
- package/src/elements/scrollbar.d.ts +19 -0
- package/src/elements/scrollbar.js +299 -0
- package/src/elements/search_bar.d.ts +27 -0
- package/src/elements/search_bar.js +98 -0
- package/src/elements/select.d.ts +26 -0
- package/src/elements/select.js +485 -0
- package/src/elements/sidebar.d.ts +21 -0
- package/src/elements/sidebar.js +322 -0
- package/src/elements/skeleton.d.ts +17 -0
- package/src/elements/skeleton.js +31 -0
- package/src/elements/slider.d.ts +28 -0
- package/src/elements/slider.js +93 -0
- package/src/elements/speed_dial.d.ts +23 -0
- package/src/elements/speed_dial.js +370 -0
- package/src/elements/spinner.d.ts +15 -0
- package/src/elements/spinner.js +28 -0
- package/src/elements/split_button.d.ts +23 -0
- package/src/elements/split_button.js +281 -0
- package/src/elements/stepper.d.ts +20 -0
- package/src/elements/stepper.js +31 -0
- package/src/elements/switch.d.ts +22 -0
- package/src/elements/switch.js +129 -0
- package/src/elements/table.d.ts +29 -0
- package/src/elements/table.js +371 -0
- package/src/elements/tabs.d.ts +19 -0
- package/src/elements/tabs.js +139 -0
- package/src/elements/text.d.ts +26 -0
- package/src/elements/text.js +32 -0
- package/src/elements/text_input.d.ts +36 -0
- package/src/elements/text_input.js +121 -0
- package/src/elements/thinking.d.ts +17 -0
- package/src/elements/thinking.js +28 -0
- package/src/elements/toast.d.ts +23 -0
- package/src/elements/toast.js +209 -0
- package/src/elements/toggle_group.d.ts +22 -0
- package/src/elements/toggle_group.js +176 -0
- package/src/elements/tooltip.d.ts +18 -0
- package/src/elements/tooltip.js +64 -0
- package/src/markdown.d.ts +24 -0
- package/src/markdown.js +66 -0
- package/src/runtime.d.ts +35 -0
- package/src/runtime.js +790 -0
- package/src/server.d.ts +69 -0
- package/src/server.js +176 -0
- package/src/streaming-markdown.js +43 -0
- package/src/vite-plugin.d.ts +46 -0
- package/src/vite-plugin.js +221 -0
- package/wasm/package.json +16 -0
- package/wasm/wasm_api.d.ts +72 -0
- package/wasm/wasm_api.js +593 -0
- package/wasm/wasm_api_bg.wasm +0 -0
- package/wasm/wasm_api_bg.wasm.d.ts +10 -0
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
// Custom behavior for <cx-carousel> — Embla-style attraction physics,
|
|
2
|
+
// motion blur, morphing cursor, smart text contrast, and drag/swipe navigation.
|
|
3
|
+
//
|
|
4
|
+
// This is a faithful port of static/_behaviors/carousel.js (the SSR gallery
|
|
5
|
+
// behavior) into the Custom Element lifecycle. The physics model is identical:
|
|
6
|
+
// rAF attraction loop with exponential friction, velocity-projected snap targets,
|
|
7
|
+
// pointer ring buffer, rubber-band boundaries, and per-slide SVG motion blur.
|
|
8
|
+
//
|
|
9
|
+
// Source: crates/wasm-api/src/carousel.rs (rendering)
|
|
10
|
+
// static/_behaviors/carousel.js (interaction reference)
|
|
11
|
+
|
|
12
|
+
export function defineCxCarousel(wasmFn, baseClass) {
|
|
13
|
+
// ─── Physics tuning (matches SSR behavior exactly) ───
|
|
14
|
+
const ATTRACT_DURATION = 25;
|
|
15
|
+
const ATTRACT_FRICTION = 0.68;
|
|
16
|
+
const SETTLE_THRESHOLD = 0.5;
|
|
17
|
+
const DRAG_THRESHOLD = 8;
|
|
18
|
+
const MOMENTUM_FACTOR = 0.4;
|
|
19
|
+
|
|
20
|
+
// Visual constants
|
|
21
|
+
const MAX_BLUR = 12;
|
|
22
|
+
const CURSOR_LERP = 0.15;
|
|
23
|
+
const CURSOR_SIZE = 40;
|
|
24
|
+
const CURSOR_MORPH_W = 80;
|
|
25
|
+
const ACTIVE_OPACITY = '1';
|
|
26
|
+
const INACTIVE_OPACITY = '0.6';
|
|
27
|
+
const ACTIVE_SCALE = 'scale(1)';
|
|
28
|
+
const INACTIVE_SCALE = 'scale(0.97)';
|
|
29
|
+
|
|
30
|
+
class CxCarousel extends baseClass {
|
|
31
|
+
static observedAttributes = ['id', 'label', 'variant', 'shape', 'size', 'slides', 'autoplay', 'autoplay-interval', 'loop-mode', 'motion-blur', 'custom-cursor', 'indicator'];
|
|
32
|
+
static _booleanAttrs = new Set(['loop-mode', 'motion-blur', 'custom-cursor']);
|
|
33
|
+
static _numericAttrs = new Set(['autoplay-interval']);
|
|
34
|
+
static _hostDisplay = 'block';
|
|
35
|
+
static _focusable = false;
|
|
36
|
+
|
|
37
|
+
#cr = null;
|
|
38
|
+
|
|
39
|
+
connectedCallback() {
|
|
40
|
+
if (!this._isInitialized) {
|
|
41
|
+
this._markInitialized();
|
|
42
|
+
}
|
|
43
|
+
super.connectedCallback();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
disconnectedCallback() {
|
|
47
|
+
this.#cleanup();
|
|
48
|
+
super.disconnectedCallback();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_doRender() {
|
|
52
|
+
try {
|
|
53
|
+
const result = wasmFn(this._props);
|
|
54
|
+
this._injectHtml(result);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
console.error('[cx-carousel] render:', e);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
this.#initCarousel();
|
|
61
|
+
} catch (e) {
|
|
62
|
+
console.error('[cx-carousel] init:', e);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#initCarousel() {
|
|
67
|
+
this.#cleanup();
|
|
68
|
+
const shadow = this._shadow;
|
|
69
|
+
const root = shadow.querySelector('[data-carousel]');
|
|
70
|
+
if (!root) return;
|
|
71
|
+
|
|
72
|
+
const track = root.querySelector('[data-carousel-track]');
|
|
73
|
+
if (!track) return;
|
|
74
|
+
|
|
75
|
+
const slides = Array.from(root.querySelectorAll('[data-carousel-slide]'));
|
|
76
|
+
if (!slides.length) return;
|
|
77
|
+
|
|
78
|
+
// Read state from embedded JSON script
|
|
79
|
+
const id = root.getAttribute('data-carousel') || '';
|
|
80
|
+
const stateEl = root.querySelector(`script[data-state="${id}"]`) || root.querySelector('script[data-state]');
|
|
81
|
+
let cfg = {};
|
|
82
|
+
try { if (stateEl) cfg = JSON.parse(stateEl.textContent); } catch (_) {}
|
|
83
|
+
|
|
84
|
+
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
85
|
+
const hasFinePointer = window.matchMedia?.('(pointer: fine)').matches;
|
|
86
|
+
|
|
87
|
+
const cr = {
|
|
88
|
+
root,
|
|
89
|
+
track,
|
|
90
|
+
slides,
|
|
91
|
+
id,
|
|
92
|
+
current: cfg.current || 0,
|
|
93
|
+
total: slides.length,
|
|
94
|
+
loop: cfg.loop !== false,
|
|
95
|
+
autoplayOn: cfg.autoplay === 'on',
|
|
96
|
+
interval: cfg.interval || 5000,
|
|
97
|
+
useBlur: (cfg.motionBlur !== false) && !prefersReducedMotion,
|
|
98
|
+
useCursor: (cfg.customCursor !== false) && !prefersReducedMotion && hasFinePointer,
|
|
99
|
+
prefersReducedMotion,
|
|
100
|
+
hasFinePointer,
|
|
101
|
+
// Physics state
|
|
102
|
+
snapPositions: [],
|
|
103
|
+
trackOffset: 0,
|
|
104
|
+
velocity: 0,
|
|
105
|
+
animFrame: null,
|
|
106
|
+
isAnimating: false,
|
|
107
|
+
// Drag state
|
|
108
|
+
isDragging: false,
|
|
109
|
+
wasDragging: false,
|
|
110
|
+
dragStartX: 0,
|
|
111
|
+
dragStartOffset: 0,
|
|
112
|
+
dragStartSlideIndex: -1,
|
|
113
|
+
pointerHistory: [],
|
|
114
|
+
// Autoplay state
|
|
115
|
+
autoplayTimer: null,
|
|
116
|
+
isPlaying: false,
|
|
117
|
+
// Cursor state
|
|
118
|
+
cursorX: 0, cursorY: 0,
|
|
119
|
+
targetCursorX: 0, targetCursorY: 0,
|
|
120
|
+
cursorVisible: false,
|
|
121
|
+
cursorFrame: null,
|
|
122
|
+
prevMoveX: 0,
|
|
123
|
+
cursorMorphAmount: 0,
|
|
124
|
+
cursorW: CURSOR_SIZE,
|
|
125
|
+
// DOM refs
|
|
126
|
+
blurFilter: root.querySelector('[data-carousel-blur]'),
|
|
127
|
+
cursorEl: root.querySelector('[data-carousel-cursor]'),
|
|
128
|
+
liveRegion: root.querySelector('[data-carousel-live]'),
|
|
129
|
+
playBtn: root.querySelector('[data-carousel-play]'),
|
|
130
|
+
progressFill: root.querySelector('[data-carousel-progress-fill]'),
|
|
131
|
+
indicators: Array.from(root.querySelectorAll('[data-carousel-dot]')),
|
|
132
|
+
prevBtn: root.querySelector('[data-carousel-prev]'),
|
|
133
|
+
nextBtn: root.querySelector('[data-carousel-next]'),
|
|
134
|
+
// Cleanup
|
|
135
|
+
cleanups: [],
|
|
136
|
+
};
|
|
137
|
+
this.#cr = cr;
|
|
138
|
+
|
|
139
|
+
// Make root focusable for keyboard navigation
|
|
140
|
+
if (!root.hasAttribute('tabindex')) {
|
|
141
|
+
root.setAttribute('tabindex', '0');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Remove SSR inert so pointer events work for drag
|
|
145
|
+
slides.forEach(s => s.removeAttribute('inert'));
|
|
146
|
+
|
|
147
|
+
// ── Smart text contrast (Editorial only) ──
|
|
148
|
+
const isCardVariant = root.getAttribute('data-variant') === 'card';
|
|
149
|
+
if (!isCardVariant) {
|
|
150
|
+
slides.forEach(slide => this.#analyzeSlideContrast(slide));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Measure snap positions (after layout) ──
|
|
154
|
+
requestAnimationFrame(() => {
|
|
155
|
+
this.#measureSnaps();
|
|
156
|
+
cr.trackOffset = cr.snapPositions[cr.current] || 0;
|
|
157
|
+
track.style.transform = `translate3d(${cr.trackOffset}px,0,0)`;
|
|
158
|
+
track.style.willChange = 'transform';
|
|
159
|
+
this.#applySlideVisuals();
|
|
160
|
+
this.#updateNavButtons();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ── Keyboard ──
|
|
164
|
+
const onKey = e => this.#handleKey(e);
|
|
165
|
+
root.addEventListener('keydown', onKey);
|
|
166
|
+
cr.cleanups.push(() => root.removeEventListener('keydown', onKey));
|
|
167
|
+
|
|
168
|
+
// ── Pointer drag ──
|
|
169
|
+
const onPointerDown = e => {
|
|
170
|
+
if (e.button !== 0) return;
|
|
171
|
+
cr.isDragging = true;
|
|
172
|
+
cr.wasDragging = false;
|
|
173
|
+
cr.dragStartX = e.clientX;
|
|
174
|
+
cr.pointerHistory = [{ x: e.clientX, t: performance.now() }];
|
|
175
|
+
|
|
176
|
+
// Record which slide was touched (for intent detection)
|
|
177
|
+
cr.dragStartSlideIndex = -1;
|
|
178
|
+
const el = e.target.closest('[data-carousel-slide]');
|
|
179
|
+
if (el) {
|
|
180
|
+
const idx = parseInt(el.getAttribute('data-carousel-slide'), 10);
|
|
181
|
+
if (!isNaN(idx)) cr.dragStartSlideIndex = idx;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Cancel any running animation and clear residual blur
|
|
185
|
+
if (cr.animFrame) {
|
|
186
|
+
cancelAnimationFrame(cr.animFrame);
|
|
187
|
+
cr.animFrame = null;
|
|
188
|
+
cr.isAnimating = false;
|
|
189
|
+
this.#setBlur(0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
cr.dragStartOffset = cr.trackOffset;
|
|
193
|
+
this.#pauseAutoplay();
|
|
194
|
+
track.setPointerCapture(e.pointerId);
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
};
|
|
197
|
+
track.addEventListener('pointerdown', onPointerDown);
|
|
198
|
+
cr.cleanups.push(() => track.removeEventListener('pointerdown', onPointerDown));
|
|
199
|
+
|
|
200
|
+
const onPointerMove = e => {
|
|
201
|
+
if (cr.useCursor && cr.hasFinePointer) {
|
|
202
|
+
cr.targetCursorX = e.clientX;
|
|
203
|
+
cr.targetCursorY = e.clientY;
|
|
204
|
+
}
|
|
205
|
+
if (!cr.isDragging) return;
|
|
206
|
+
|
|
207
|
+
const dx = e.clientX - cr.dragStartX;
|
|
208
|
+
if (Math.abs(dx) > DRAG_THRESHOLD) cr.wasDragging = true;
|
|
209
|
+
|
|
210
|
+
cr.trackOffset = cr.dragStartOffset + dx;
|
|
211
|
+
|
|
212
|
+
// Rubber-band at boundaries
|
|
213
|
+
let maxScroll = track.scrollWidth - root.offsetWidth;
|
|
214
|
+
if (maxScroll < 0) maxScroll = 0;
|
|
215
|
+
if (cr.trackOffset > 0) {
|
|
216
|
+
cr.trackOffset *= 0.3;
|
|
217
|
+
} else if (cr.trackOffset < -maxScroll) {
|
|
218
|
+
cr.trackOffset = -maxScroll + (cr.trackOffset + maxScroll) * 0.3;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
track.style.transform = `translate3d(${cr.trackOffset}px,0,0)`;
|
|
222
|
+
|
|
223
|
+
// Velocity tracking (ring buffer of last 5 positions)
|
|
224
|
+
cr.pointerHistory.push({ x: e.clientX, t: performance.now() });
|
|
225
|
+
if (cr.pointerHistory.length > 5) cr.pointerHistory.shift();
|
|
226
|
+
|
|
227
|
+
// Live blur during drag
|
|
228
|
+
if (cr.useBlur && cr.pointerHistory.length >= 2) {
|
|
229
|
+
const last = cr.pointerHistory[cr.pointerHistory.length - 1];
|
|
230
|
+
const prev = cr.pointerHistory[cr.pointerHistory.length - 2];
|
|
231
|
+
const dt = (last.t - prev.t) / 1000;
|
|
232
|
+
if (dt > 0) this.#setBlur(Math.min(Math.abs((last.x - prev.x) / dt) * 0.005, MAX_BLUR));
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
track.addEventListener('pointermove', onPointerMove);
|
|
236
|
+
cr.cleanups.push(() => track.removeEventListener('pointermove', onPointerMove));
|
|
237
|
+
|
|
238
|
+
const onPointerUp = () => {
|
|
239
|
+
if (!cr.isDragging) return;
|
|
240
|
+
cr.isDragging = false;
|
|
241
|
+
|
|
242
|
+
// Calculate release velocity from last 3 pointer samples
|
|
243
|
+
let releaseVelocity = 0;
|
|
244
|
+
if (cr.pointerHistory.length >= 2) {
|
|
245
|
+
const si = Math.max(0, cr.pointerHistory.length - 3);
|
|
246
|
+
const first = cr.pointerHistory[si];
|
|
247
|
+
const last = cr.pointerHistory[cr.pointerHistory.length - 1];
|
|
248
|
+
const dt = (last.t - first.t) / 1000;
|
|
249
|
+
if (dt > 0) releaseVelocity = (last.x - first.x) / dt;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let targetIndex;
|
|
253
|
+
|
|
254
|
+
// Intent detection: user grabbed an ADJACENT non-active slide and
|
|
255
|
+
// dragged it toward center
|
|
256
|
+
const dragDx = cr.trackOffset - cr.dragStartOffset;
|
|
257
|
+
if (
|
|
258
|
+
cr.dragStartSlideIndex >= 0 &&
|
|
259
|
+
cr.dragStartSlideIndex !== cr.current &&
|
|
260
|
+
Math.abs(cr.dragStartSlideIndex - cr.current) === 1 &&
|
|
261
|
+
cr.wasDragging
|
|
262
|
+
) {
|
|
263
|
+
const toRight = cr.dragStartSlideIndex > cr.current;
|
|
264
|
+
const towardCenter = (toRight && dragDx < 0) || (!toRight && dragDx > 0);
|
|
265
|
+
targetIndex = towardCenter ? cr.dragStartSlideIndex : cr.current;
|
|
266
|
+
} else {
|
|
267
|
+
// Loop wrap at boundaries
|
|
268
|
+
if (cr.loop && cr.wasDragging) {
|
|
269
|
+
if (cr.current === cr.total - 1 && releaseVelocity < -50) {
|
|
270
|
+
targetIndex = 0;
|
|
271
|
+
} else if (cr.current === 0 && releaseVelocity > 50) {
|
|
272
|
+
targetIndex = cr.total - 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Normal snap resolution
|
|
276
|
+
if (targetIndex === undefined) {
|
|
277
|
+
targetIndex = this.#findSnapTarget(cr.trackOffset, releaseVelocity);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Navigate or snap back
|
|
282
|
+
if (targetIndex !== cr.current) {
|
|
283
|
+
this.#goToSlide(targetIndex);
|
|
284
|
+
} else {
|
|
285
|
+
const snapPos = cr.snapPositions[cr.current] || 0;
|
|
286
|
+
if (Math.abs(cr.trackOffset - snapPos) > 1) {
|
|
287
|
+
this.#attractTo(snapPos);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
track.addEventListener('pointerup', onPointerUp);
|
|
292
|
+
cr.cleanups.push(() => track.removeEventListener('pointerup', onPointerUp));
|
|
293
|
+
|
|
294
|
+
const onPointerCancel = () => {
|
|
295
|
+
if (!cr.isDragging) return;
|
|
296
|
+
cr.isDragging = false;
|
|
297
|
+
this.#setBlur(0);
|
|
298
|
+
this.#attractTo(cr.snapPositions[cr.current] || 0);
|
|
299
|
+
};
|
|
300
|
+
track.addEventListener('pointercancel', onPointerCancel);
|
|
301
|
+
cr.cleanups.push(() => track.removeEventListener('pointercancel', onPointerCancel));
|
|
302
|
+
|
|
303
|
+
// ── Click navigation (click non-active slide to navigate) ──
|
|
304
|
+
const onClick = e => {
|
|
305
|
+
if (cr.wasDragging) { cr.wasDragging = false; e.preventDefault(); return; }
|
|
306
|
+
const slideEl = e.target.closest('[data-carousel-slide]');
|
|
307
|
+
if (!slideEl || slideEl.hasAttribute('data-active')) return;
|
|
308
|
+
const idx = parseInt(slideEl.getAttribute('data-carousel-slide'), 10);
|
|
309
|
+
if (!isNaN(idx)) this.#goToSlide(idx);
|
|
310
|
+
};
|
|
311
|
+
track.addEventListener('click', onClick);
|
|
312
|
+
cr.cleanups.push(() => track.removeEventListener('click', onClick));
|
|
313
|
+
|
|
314
|
+
// ── Prev/Next buttons ──
|
|
315
|
+
if (cr.prevBtn) {
|
|
316
|
+
const fn = e => { e.stopPropagation(); this.#goToSlide(cr.current - 1); };
|
|
317
|
+
cr.prevBtn.addEventListener('click', fn);
|
|
318
|
+
cr.cleanups.push(() => cr.prevBtn.removeEventListener('click', fn));
|
|
319
|
+
}
|
|
320
|
+
if (cr.nextBtn) {
|
|
321
|
+
const fn = e => { e.stopPropagation(); this.#goToSlide(cr.current + 1); };
|
|
322
|
+
cr.nextBtn.addEventListener('click', fn);
|
|
323
|
+
cr.cleanups.push(() => cr.nextBtn.removeEventListener('click', fn));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Indicator dots ──
|
|
327
|
+
cr.indicators.forEach(dot => {
|
|
328
|
+
const fn = () => {
|
|
329
|
+
const idx = parseInt(dot.getAttribute('data-carousel-dot'), 10);
|
|
330
|
+
if (!isNaN(idx)) this.#goToSlide(idx);
|
|
331
|
+
};
|
|
332
|
+
dot.addEventListener('click', fn);
|
|
333
|
+
cr.cleanups.push(() => dot.removeEventListener('click', fn));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ── Custom cursor ──
|
|
337
|
+
if (cr.useCursor && cr.cursorEl) {
|
|
338
|
+
this.#initCustomCursor();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Autoplay ──
|
|
342
|
+
if (cr.autoplayOn) {
|
|
343
|
+
if (cr.playBtn) {
|
|
344
|
+
const fn = () => this.#toggleAutoplay();
|
|
345
|
+
cr.playBtn.addEventListener('click', fn);
|
|
346
|
+
cr.cleanups.push(() => cr.playBtn.removeEventListener('click', fn));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const onEnter = () => {
|
|
350
|
+
if (cr.useCursor && cr.hasFinePointer) this.#showCursor();
|
|
351
|
+
if (cr.isPlaying) this.#pauseAutoplay();
|
|
352
|
+
};
|
|
353
|
+
const onLeave = () => {
|
|
354
|
+
this.#hideCursor();
|
|
355
|
+
};
|
|
356
|
+
root.addEventListener('mouseenter', onEnter);
|
|
357
|
+
root.addEventListener('mouseleave', onLeave);
|
|
358
|
+
cr.cleanups.push(() => {
|
|
359
|
+
root.removeEventListener('mouseenter', onEnter);
|
|
360
|
+
root.removeEventListener('mouseleave', onLeave);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const onFocusIn = () => { if (cr.isPlaying) this.#pauseAutoplay(); };
|
|
364
|
+
root.addEventListener('focusin', onFocusIn);
|
|
365
|
+
cr.cleanups.push(() => root.removeEventListener('focusin', onFocusIn));
|
|
366
|
+
|
|
367
|
+
if (!cr.prefersReducedMotion) this.#startAutoplay();
|
|
368
|
+
} else {
|
|
369
|
+
// Cursor events for non-autoplay carousels
|
|
370
|
+
if (cr.useCursor) {
|
|
371
|
+
const onEnter = e => {
|
|
372
|
+
cr.targetCursorX = cr.cursorX = e.clientX;
|
|
373
|
+
cr.targetCursorY = cr.cursorY = e.clientY;
|
|
374
|
+
this.#showCursor();
|
|
375
|
+
};
|
|
376
|
+
const onMove = e => {
|
|
377
|
+
cr.targetCursorX = e.clientX;
|
|
378
|
+
cr.targetCursorY = e.clientY;
|
|
379
|
+
};
|
|
380
|
+
const onLeave = () => this.#hideCursor();
|
|
381
|
+
root.addEventListener('mouseenter', onEnter);
|
|
382
|
+
root.addEventListener('mousemove', onMove);
|
|
383
|
+
root.addEventListener('mouseleave', onLeave);
|
|
384
|
+
cr.cleanups.push(() => {
|
|
385
|
+
root.removeEventListener('mouseenter', onEnter);
|
|
386
|
+
root.removeEventListener('mousemove', onMove);
|
|
387
|
+
root.removeEventListener('mouseleave', onLeave);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ── ResizeObserver ──
|
|
393
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
394
|
+
const ro = new ResizeObserver(() => {
|
|
395
|
+
if (!cr.isDragging && !cr.isAnimating) {
|
|
396
|
+
this.#measureSnaps();
|
|
397
|
+
cr.trackOffset = cr.snapPositions[cr.current] || 0;
|
|
398
|
+
track.style.transform = `translate3d(${cr.trackOffset}px,0,0)`;
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
ro.observe(root);
|
|
402
|
+
cr.cleanups.push(() => ro.disconnect());
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ─── Snap positions ───
|
|
407
|
+
// Computed from measured slide widths + gaps — stable across navigation.
|
|
408
|
+
|
|
409
|
+
#measureSnaps() {
|
|
410
|
+
const cr = this.#cr;
|
|
411
|
+
if (!cr) return;
|
|
412
|
+
const widths = cr.slides.map(s => s.offsetWidth);
|
|
413
|
+
const gap = cr.slides.length > 1
|
|
414
|
+
? cr.slides[1].getBoundingClientRect().left - cr.slides[0].getBoundingClientRect().right
|
|
415
|
+
: 0;
|
|
416
|
+
cr.snapPositions = [0];
|
|
417
|
+
let cumWidth = 0;
|
|
418
|
+
for (let i = 1; i < cr.slides.length; i++) {
|
|
419
|
+
cumWidth += widths[i - 1];
|
|
420
|
+
cr.snapPositions.push(-(cumWidth + gap * i));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ─── Snap target resolution ───
|
|
425
|
+
// Projects position forward by velocity, finds nearest snap among neighbors.
|
|
426
|
+
|
|
427
|
+
#findSnapTarget(position, vel) {
|
|
428
|
+
const cr = this.#cr;
|
|
429
|
+
if (!cr) return 0;
|
|
430
|
+
const candidates = [cr.current];
|
|
431
|
+
if (cr.current > 0) candidates.push(cr.current - 1);
|
|
432
|
+
if (cr.current < cr.total - 1) candidates.push(cr.current + 1);
|
|
433
|
+
|
|
434
|
+
const projected = position + vel * MOMENTUM_FACTOR;
|
|
435
|
+
let best = cr.current;
|
|
436
|
+
let bestDist = Infinity;
|
|
437
|
+
for (const idx of candidates) {
|
|
438
|
+
const d = Math.abs(projected - (cr.snapPositions[idx] || 0));
|
|
439
|
+
if (d < bestDist) { bestDist = d; best = idx; }
|
|
440
|
+
}
|
|
441
|
+
return best;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ─── Attraction animation (Embla-style) ───
|
|
445
|
+
// Exponential decay toward target — smooth, no overshoot.
|
|
446
|
+
|
|
447
|
+
#attractTo(target, onSettle) {
|
|
448
|
+
const cr = this.#cr;
|
|
449
|
+
if (!cr) return;
|
|
450
|
+
if (cr.animFrame) cancelAnimationFrame(cr.animFrame);
|
|
451
|
+
cr.velocity = 0;
|
|
452
|
+
|
|
453
|
+
if (cr.prefersReducedMotion) {
|
|
454
|
+
cr.trackOffset = target;
|
|
455
|
+
cr.track.style.transform = `translate3d(${target}px,0,0)`;
|
|
456
|
+
this.#setBlur(0);
|
|
457
|
+
if (onSettle) onSettle();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
cr.isAnimating = true;
|
|
462
|
+
const startTime = performance.now();
|
|
463
|
+
|
|
464
|
+
const settle = () => {
|
|
465
|
+
cr.trackOffset = target;
|
|
466
|
+
cr.track.style.transform = `translate3d(${target}px,0,0)`;
|
|
467
|
+
cr.velocity = 0;
|
|
468
|
+
cr.isAnimating = false;
|
|
469
|
+
cr.animFrame = null;
|
|
470
|
+
this.#setBlur(0);
|
|
471
|
+
if (onSettle) onSettle();
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const tick = () => {
|
|
475
|
+
if (performance.now() - startTime > 2000) { settle(); return; }
|
|
476
|
+
|
|
477
|
+
const displacement = target - cr.trackOffset;
|
|
478
|
+
cr.velocity += displacement / ATTRACT_DURATION;
|
|
479
|
+
cr.velocity *= ATTRACT_FRICTION;
|
|
480
|
+
cr.trackOffset += cr.velocity;
|
|
481
|
+
|
|
482
|
+
cr.track.style.transform = `translate3d(${cr.trackOffset}px,0,0)`;
|
|
483
|
+
|
|
484
|
+
if (cr.useBlur) this.#setBlur(Math.min(Math.abs(cr.velocity) * 0.15, MAX_BLUR));
|
|
485
|
+
|
|
486
|
+
if (Math.abs(cr.velocity) < SETTLE_THRESHOLD && Math.abs(displacement) < SETTLE_THRESHOLD) {
|
|
487
|
+
settle();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
cr.animFrame = requestAnimationFrame(tick);
|
|
491
|
+
};
|
|
492
|
+
cr.animFrame = requestAnimationFrame(tick);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ─── Navigation ───
|
|
496
|
+
|
|
497
|
+
#goToSlide(index, fromAutoplay) {
|
|
498
|
+
const cr = this.#cr;
|
|
499
|
+
if (!cr) return;
|
|
500
|
+
|
|
501
|
+
// Clamp or wrap
|
|
502
|
+
if (cr.loop) {
|
|
503
|
+
if (index < 0) index = cr.total - 1;
|
|
504
|
+
if (index >= cr.total) index = 0;
|
|
505
|
+
} else {
|
|
506
|
+
if (index < 0) index = 0;
|
|
507
|
+
if (index >= cr.total) index = cr.total - 1;
|
|
508
|
+
}
|
|
509
|
+
if (index === cr.current && !cr.isAnimating) return;
|
|
510
|
+
|
|
511
|
+
const isFarJump = Math.abs(index - cr.current) > 1;
|
|
512
|
+
cr.current = index;
|
|
513
|
+
this.#applySlideVisuals();
|
|
514
|
+
this.#updateLiveRegion();
|
|
515
|
+
this.#updateNavButtons();
|
|
516
|
+
|
|
517
|
+
const target = cr.snapPositions[cr.current] || 0;
|
|
518
|
+
|
|
519
|
+
if (isFarJump) {
|
|
520
|
+
this.#farJumpAnimation(target, fromAutoplay);
|
|
521
|
+
} else {
|
|
522
|
+
this.#attractTo(target, () => {
|
|
523
|
+
if (cr.autoplayOn && cr.isPlaying && fromAutoplay) this.#startAutoplay();
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (!fromAutoplay) this.#pauseAutoplay();
|
|
528
|
+
this._emit('cx-change', { index, total: cr.total });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ─── Far-jump animation ───
|
|
532
|
+
// Motion-blur wipe: overshoot → teleport under max blur → decelerate into target.
|
|
533
|
+
|
|
534
|
+
#farJumpAnimation(target, fromAutoplay) {
|
|
535
|
+
const cr = this.#cr;
|
|
536
|
+
if (!cr) return;
|
|
537
|
+
|
|
538
|
+
if (cr.animFrame) { cancelAnimationFrame(cr.animFrame); cr.animFrame = null; }
|
|
539
|
+
|
|
540
|
+
if (cr.prefersReducedMotion) {
|
|
541
|
+
cr.trackOffset = target;
|
|
542
|
+
cr.track.style.transform = `translate3d(${target}px,0,0)`;
|
|
543
|
+
if (cr.autoplayOn && cr.isPlaying && fromAutoplay) this.#startAutoplay();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
cr.isAnimating = true;
|
|
548
|
+
|
|
549
|
+
const slideWidth = cr.slides[0] ? cr.slides[0].offsetWidth : 300;
|
|
550
|
+
const overshoot = slideWidth * 0.6;
|
|
551
|
+
const goingForward = (cr.current === 0);
|
|
552
|
+
const dir = goingForward ? -1 : 1;
|
|
553
|
+
|
|
554
|
+
const phase1Start = cr.trackOffset;
|
|
555
|
+
const phase1Target = cr.trackOffset + dir * overshoot;
|
|
556
|
+
let startTime = performance.now();
|
|
557
|
+
const PHASE1_MS = 80;
|
|
558
|
+
const PHASE2_MS = 60;
|
|
559
|
+
|
|
560
|
+
const setWipeBlur = (amount) => {
|
|
561
|
+
if (!cr.blurFilter) return;
|
|
562
|
+
cr.blurFilter.setAttribute('stdDeviation', amount.toFixed(1) + ',0');
|
|
563
|
+
const filterUrl = `url(#${cr.id}-blur)`;
|
|
564
|
+
cr.slides.forEach(s => {
|
|
565
|
+
s.style.filter = amount > 0.1 ? filterUrl : '';
|
|
566
|
+
});
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const phase1 = (now) => {
|
|
570
|
+
const elapsed = now - startTime;
|
|
571
|
+
const t = Math.min(elapsed / PHASE1_MS, 1);
|
|
572
|
+
const eased = t * t;
|
|
573
|
+
cr.trackOffset = phase1Start + (phase1Target - phase1Start) * eased;
|
|
574
|
+
cr.track.style.transform = `translate3d(${cr.trackOffset}px,0,0)`;
|
|
575
|
+
setWipeBlur(MAX_BLUR * eased);
|
|
576
|
+
|
|
577
|
+
if (t < 1) {
|
|
578
|
+
cr.animFrame = requestAnimationFrame(phase1);
|
|
579
|
+
} else {
|
|
580
|
+
cr.trackOffset = target - dir * overshoot;
|
|
581
|
+
cr.track.style.transform = `translate3d(${cr.trackOffset}px,0,0)`;
|
|
582
|
+
startTime = performance.now();
|
|
583
|
+
cr.animFrame = requestAnimationFrame(phase2);
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const phase2 = (now) => {
|
|
588
|
+
const elapsed = now - startTime;
|
|
589
|
+
const t = Math.min(elapsed / PHASE2_MS, 1);
|
|
590
|
+
const eased = 1 - (1 - t) * (1 - t);
|
|
591
|
+
cr.trackOffset = (target - dir * overshoot) + dir * overshoot * eased;
|
|
592
|
+
cr.track.style.transform = `translate3d(${cr.trackOffset}px,0,0)`;
|
|
593
|
+
setWipeBlur(MAX_BLUR * (1 - eased));
|
|
594
|
+
|
|
595
|
+
if (t < 1) {
|
|
596
|
+
cr.animFrame = requestAnimationFrame(phase2);
|
|
597
|
+
} else {
|
|
598
|
+
cr.trackOffset = target;
|
|
599
|
+
cr.track.style.transform = `translate3d(${target}px,0,0)`;
|
|
600
|
+
setWipeBlur(0);
|
|
601
|
+
cr.isAnimating = false;
|
|
602
|
+
cr.animFrame = null;
|
|
603
|
+
if (cr.autoplayOn && cr.isPlaying && fromAutoplay) this.#startAutoplay();
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
cr.animFrame = requestAnimationFrame(phase1);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ─── Slide visuals ───
|
|
611
|
+
|
|
612
|
+
#applySlideVisuals() {
|
|
613
|
+
const cr = this.#cr;
|
|
614
|
+
if (!cr) return;
|
|
615
|
+
|
|
616
|
+
cr.slides.forEach((s, i) => {
|
|
617
|
+
if (i === cr.current) {
|
|
618
|
+
s.setAttribute('data-active', '');
|
|
619
|
+
s.removeAttribute('aria-hidden');
|
|
620
|
+
s.style.opacity = ACTIVE_OPACITY;
|
|
621
|
+
s.style.transform = ACTIVE_SCALE;
|
|
622
|
+
} else {
|
|
623
|
+
s.removeAttribute('data-active');
|
|
624
|
+
s.setAttribute('aria-hidden', 'true');
|
|
625
|
+
s.style.opacity = INACTIVE_OPACITY;
|
|
626
|
+
s.style.transform = INACTIVE_SCALE;
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
this.#updateIndicators();
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
#updateIndicators() {
|
|
633
|
+
const cr = this.#cr;
|
|
634
|
+
if (!cr || !cr.indicators.length) return;
|
|
635
|
+
|
|
636
|
+
const isDot = cr.indicators[0].classList.contains('rounded-full');
|
|
637
|
+
cr.indicators.forEach((dot, i) => {
|
|
638
|
+
if (i === cr.current) {
|
|
639
|
+
dot.setAttribute('data-active', '');
|
|
640
|
+
dot.style.background = 'var(--color-text)';
|
|
641
|
+
dot.style.width = isDot ? '10px' : '28px';
|
|
642
|
+
dot.style.height = isDot ? '10px' : '8px';
|
|
643
|
+
} else {
|
|
644
|
+
dot.removeAttribute('data-active');
|
|
645
|
+
dot.style.background = 'color-mix(in oklch, var(--color-text-muted) 40%, transparent)';
|
|
646
|
+
dot.style.width = isDot ? '8px' : '20px';
|
|
647
|
+
dot.style.height = isDot ? '8px' : '8px';
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
#updateLiveRegion() {
|
|
653
|
+
const cr = this.#cr;
|
|
654
|
+
if (!cr || !cr.liveRegion) return;
|
|
655
|
+
cr.liveRegion.textContent = `Slide ${cr.current + 1} of ${cr.total}`;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
#updateNavButtons() {
|
|
659
|
+
const cr = this.#cr;
|
|
660
|
+
if (!cr) return;
|
|
661
|
+
if (cr.loop) return; // Both always enabled in loop mode
|
|
662
|
+
|
|
663
|
+
if (cr.prevBtn) {
|
|
664
|
+
if (cr.current === 0) {
|
|
665
|
+
cr.prevBtn.setAttribute('disabled', '');
|
|
666
|
+
cr.prevBtn.style.opacity = '0.3';
|
|
667
|
+
cr.prevBtn.style.pointerEvents = 'none';
|
|
668
|
+
} else {
|
|
669
|
+
cr.prevBtn.removeAttribute('disabled');
|
|
670
|
+
cr.prevBtn.style.opacity = '';
|
|
671
|
+
cr.prevBtn.style.pointerEvents = '';
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (cr.nextBtn) {
|
|
675
|
+
if (cr.current === cr.total - 1) {
|
|
676
|
+
cr.nextBtn.setAttribute('disabled', '');
|
|
677
|
+
cr.nextBtn.style.opacity = '0.3';
|
|
678
|
+
cr.nextBtn.style.pointerEvents = 'none';
|
|
679
|
+
} else {
|
|
680
|
+
cr.nextBtn.removeAttribute('disabled');
|
|
681
|
+
cr.nextBtn.style.opacity = '';
|
|
682
|
+
cr.nextBtn.style.pointerEvents = '';
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ─── Motion blur (SVG per-slide, matches SSR) ───
|
|
688
|
+
|
|
689
|
+
#setBlur(amount) {
|
|
690
|
+
const cr = this.#cr;
|
|
691
|
+
if (!cr || !cr.blurFilter) return;
|
|
692
|
+
cr.blurFilter.setAttribute('stdDeviation', amount.toFixed(1) + ',0');
|
|
693
|
+
cr.slides.forEach((s, i) => {
|
|
694
|
+
if (i !== cr.current && amount > 0.1) {
|
|
695
|
+
s.style.filter = `url(#${cr.id}-blur)`;
|
|
696
|
+
} else {
|
|
697
|
+
s.style.filter = '';
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ─── Smart text contrast ───
|
|
703
|
+
// Canvas pixel analysis of bottom 35% of slide image — matches SSR behavior.
|
|
704
|
+
|
|
705
|
+
#analyzeSlideContrast(slide) {
|
|
706
|
+
const origImg = slide.querySelector('img');
|
|
707
|
+
if (!origImg) return;
|
|
708
|
+
const doAnalysis = () => {
|
|
709
|
+
const testImg = new Image();
|
|
710
|
+
testImg.crossOrigin = 'anonymous';
|
|
711
|
+
testImg.onload = () => this.#analyzePixels(slide, testImg);
|
|
712
|
+
testImg.onerror = () => this.#applyContrast(slide, 0.3);
|
|
713
|
+
testImg.src = origImg.src;
|
|
714
|
+
};
|
|
715
|
+
if (origImg.complete && origImg.naturalWidth > 0) doAnalysis();
|
|
716
|
+
else origImg.addEventListener('load', doAnalysis, { once: true });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
#analyzePixels(slide, img) {
|
|
720
|
+
const canvas = document.createElement('canvas');
|
|
721
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
722
|
+
if (!ctx) { this.#applyContrast(slide, 0.3); return; }
|
|
723
|
+
const w = img.naturalWidth || img.width;
|
|
724
|
+
const h = img.naturalHeight || img.height;
|
|
725
|
+
if (w === 0 || h === 0) return;
|
|
726
|
+
const sampleH = Math.floor(h * 0.35);
|
|
727
|
+
const sw = Math.min(w, 80);
|
|
728
|
+
const sh = Math.min(sampleH, 30);
|
|
729
|
+
canvas.width = sw;
|
|
730
|
+
canvas.height = sh;
|
|
731
|
+
try {
|
|
732
|
+
ctx.drawImage(img, 0, h - sampleH, w, sampleH, 0, 0, sw, sh);
|
|
733
|
+
const data = ctx.getImageData(0, 0, sw, sh).data;
|
|
734
|
+
let totalLum = 0;
|
|
735
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
736
|
+
totalLum += (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255;
|
|
737
|
+
}
|
|
738
|
+
this.#applyContrast(slide, totalLum / (data.length / 4));
|
|
739
|
+
} catch (_) { this.#applyContrast(slide, 0.3); }
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
#applyContrast(slide, luminance) {
|
|
743
|
+
const dark = luminance < 0.5;
|
|
744
|
+
const title = slide.querySelector('[data-carousel-title]');
|
|
745
|
+
const subtitle = slide.querySelector('[data-carousel-subtitle]');
|
|
746
|
+
const link = slide.querySelector('[data-carousel-link]');
|
|
747
|
+
if (title) title.style.color = dark ? 'white' : 'rgba(15,23,42,0.95)';
|
|
748
|
+
if (subtitle) subtitle.style.color = dark ? 'rgba(255,255,255,0.75)' : 'rgba(15,23,42,0.6)';
|
|
749
|
+
if (link) link.style.color = dark ? 'rgba(255,255,255,0.9)' : 'rgba(15,23,42,0.85)';
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ─── Custom cursor (morphing pill with velocity lerp) ───
|
|
753
|
+
|
|
754
|
+
#initCustomCursor() {
|
|
755
|
+
const cr = this.#cr;
|
|
756
|
+
if (!cr || !cr.cursorEl) return;
|
|
757
|
+
|
|
758
|
+
// Cursor events are managed via root mouseenter/mousemove/mouseleave
|
|
759
|
+
// which are already set up in the autoplay and non-autoplay branches.
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
#showCursor() {
|
|
763
|
+
const cr = this.#cr;
|
|
764
|
+
if (!cr || !cr.useCursor || !cr.cursorEl) return;
|
|
765
|
+
cr.cursorEl.style.opacity = '1';
|
|
766
|
+
cr.cursorVisible = true;
|
|
767
|
+
cr.root.style.cursor = 'none';
|
|
768
|
+
if (!cr.cursorFrame) cr.cursorFrame = requestAnimationFrame(() => this.#tickCursor());
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
#hideCursor() {
|
|
772
|
+
const cr = this.#cr;
|
|
773
|
+
if (!cr || !cr.cursorEl) return;
|
|
774
|
+
cr.cursorEl.style.opacity = '0';
|
|
775
|
+
cr.cursorVisible = false;
|
|
776
|
+
cr.root.style.cursor = '';
|
|
777
|
+
if (cr.cursorFrame) { cancelAnimationFrame(cr.cursorFrame); cr.cursorFrame = null; }
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
#tickCursor() {
|
|
781
|
+
const cr = this.#cr;
|
|
782
|
+
if (!cr || !cr.useCursor || !cr.cursorEl) return;
|
|
783
|
+
|
|
784
|
+
cr.cursorX += (cr.targetCursorX - cr.cursorX) * CURSOR_LERP;
|
|
785
|
+
cr.cursorY += (cr.targetCursorY - cr.cursorY) * CURSOR_LERP;
|
|
786
|
+
const moveVel = cr.targetCursorX - cr.prevMoveX;
|
|
787
|
+
cr.prevMoveX = cr.targetCursorX;
|
|
788
|
+
|
|
789
|
+
// Morph shape based on velocity
|
|
790
|
+
const morphTarget = (cr.isDragging || cr.isAnimating) ? Math.min(Math.abs(moveVel * 3) / 600, 1) : 0;
|
|
791
|
+
const morphLerp = morphTarget > cr.cursorMorphAmount ? 0.35 : 0.25;
|
|
792
|
+
cr.cursorMorphAmount += (morphTarget - cr.cursorMorphAmount) * morphLerp;
|
|
793
|
+
const w = CURSOR_SIZE + (CURSOR_MORPH_W - CURSOR_SIZE) * cr.cursorMorphAmount;
|
|
794
|
+
const r = 9999 - (9999 - 12) * cr.cursorMorphAmount;
|
|
795
|
+
cr.cursorW = w;
|
|
796
|
+
cr.cursorEl.style.width = w.toFixed(1) + 'px';
|
|
797
|
+
cr.cursorEl.style.height = CURSOR_SIZE + 'px';
|
|
798
|
+
cr.cursorEl.style.borderRadius = r.toFixed(0) + 'px';
|
|
799
|
+
cr.cursorEl.style.transform = `translate3d(${(cr.cursorX - cr.cursorW / 2).toFixed(1)}px,${(cr.cursorY - CURSOR_SIZE / 2).toFixed(1)}px,0)`;
|
|
800
|
+
|
|
801
|
+
if (cr.cursorVisible) cr.cursorFrame = requestAnimationFrame(() => this.#tickCursor());
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ─── Autoplay (rAF-driven with progress bar) ───
|
|
805
|
+
|
|
806
|
+
#startAutoplay() {
|
|
807
|
+
const cr = this.#cr;
|
|
808
|
+
if (!cr || !cr.autoplayOn || cr.prefersReducedMotion) return;
|
|
809
|
+
cr.isPlaying = true;
|
|
810
|
+
const startTime = performance.now();
|
|
811
|
+
if (cr.playBtn) {
|
|
812
|
+
cr.playBtn.setAttribute('aria-label', 'Stop automatic slide show');
|
|
813
|
+
cr.playBtn.innerHTML = '<svg aria-hidden="true" class="size-5 md:size-6" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>';
|
|
814
|
+
}
|
|
815
|
+
if (cr.liveRegion) cr.liveRegion.setAttribute('aria-live', 'off');
|
|
816
|
+
|
|
817
|
+
const tick = (now) => {
|
|
818
|
+
if (!cr.isPlaying) return;
|
|
819
|
+
const progress = (now - startTime) / cr.interval;
|
|
820
|
+
if (cr.progressFill) cr.progressFill.style.width = (Math.min(progress, 1) * 100) + '%';
|
|
821
|
+
if (progress >= 1) {
|
|
822
|
+
this.#goToSlide(cr.current + 1, true);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
cr.autoplayTimer = requestAnimationFrame(tick);
|
|
826
|
+
};
|
|
827
|
+
cr.autoplayTimer = requestAnimationFrame(tick);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
#pauseAutoplay() {
|
|
831
|
+
const cr = this.#cr;
|
|
832
|
+
if (!cr) return;
|
|
833
|
+
cr.isPlaying = false;
|
|
834
|
+
if (cr.autoplayTimer) { cancelAnimationFrame(cr.autoplayTimer); cr.autoplayTimer = null; }
|
|
835
|
+
if (cr.progressFill) cr.progressFill.style.width = '0%';
|
|
836
|
+
if (cr.playBtn) {
|
|
837
|
+
cr.playBtn.setAttribute('aria-label', 'Start automatic slide show');
|
|
838
|
+
cr.playBtn.innerHTML = '<svg aria-hidden="true" class="size-5 md:size-6" viewBox="0 0 24 24" fill="currentColor"><polygon points="6,4 20,12 6,20"/></svg>';
|
|
839
|
+
}
|
|
840
|
+
if (cr.liveRegion) cr.liveRegion.setAttribute('aria-live', 'polite');
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
#toggleAutoplay() {
|
|
844
|
+
const cr = this.#cr;
|
|
845
|
+
if (!cr) return;
|
|
846
|
+
if (cr.isPlaying) this.#pauseAutoplay(); else this.#startAutoplay();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// ─── Keyboard ──
|
|
850
|
+
|
|
851
|
+
#handleKey(e) {
|
|
852
|
+
const cr = this.#cr;
|
|
853
|
+
if (!cr) return;
|
|
854
|
+
switch (e.key) {
|
|
855
|
+
case 'ArrowLeft':
|
|
856
|
+
e.preventDefault();
|
|
857
|
+
this.#goToSlide(cr.current - 1);
|
|
858
|
+
break;
|
|
859
|
+
case 'ArrowRight':
|
|
860
|
+
e.preventDefault();
|
|
861
|
+
this.#goToSlide(cr.current + 1);
|
|
862
|
+
break;
|
|
863
|
+
case 'Home':
|
|
864
|
+
e.preventDefault();
|
|
865
|
+
this.#goToSlide(0);
|
|
866
|
+
break;
|
|
867
|
+
case 'End':
|
|
868
|
+
e.preventDefault();
|
|
869
|
+
this.#goToSlide(cr.total - 1);
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// ─── Public imperative API ───
|
|
875
|
+
next() { this.#goToSlide(this.#cr?.current + 1 ?? 0); }
|
|
876
|
+
prev() { this.#goToSlide(this.#cr?.current - 1 ?? 0); }
|
|
877
|
+
goTo(index) { this.#goToSlide(index); }
|
|
878
|
+
|
|
879
|
+
// ─── Cleanup ───
|
|
880
|
+
|
|
881
|
+
#cleanup() {
|
|
882
|
+
const cr = this.#cr;
|
|
883
|
+
if (!cr) return;
|
|
884
|
+
cr.isPlaying = false;
|
|
885
|
+
if (cr.animFrame) cancelAnimationFrame(cr.animFrame);
|
|
886
|
+
if (cr.autoplayTimer) cancelAnimationFrame(cr.autoplayTimer);
|
|
887
|
+
if (cr.cursorFrame) cancelAnimationFrame(cr.cursorFrame);
|
|
888
|
+
for (const fn of cr.cleanups) fn();
|
|
889
|
+
this.#cr = null;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
customElements.define('cx-carousel', CxCarousel);
|
|
894
|
+
return CxCarousel;
|
|
895
|
+
}
|