peek-carousel 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +238 -0
  3. package/README.md +234 -0
  4. package/dist/peek-carousel.css +1 -0
  5. package/dist/peek-carousel.esm.js +1368 -0
  6. package/dist/peek-carousel.esm.js.map +1 -0
  7. package/dist/peek-carousel.esm.min.js +8 -0
  8. package/dist/peek-carousel.esm.min.js.map +1 -0
  9. package/dist/peek-carousel.js +1376 -0
  10. package/dist/peek-carousel.js.map +1 -0
  11. package/dist/peek-carousel.min.css +1 -0
  12. package/dist/peek-carousel.min.js +8 -0
  13. package/dist/peek-carousel.min.js.map +1 -0
  14. package/examples/example-built.html +367 -0
  15. package/examples/example.css +2216 -0
  16. package/examples/example.js +404 -0
  17. package/examples/example.min.css +1 -0
  18. package/examples/example.min.js +1 -0
  19. package/package.json +92 -0
  20. package/src/core/PeekCarousel.js +294 -0
  21. package/src/core/config.js +49 -0
  22. package/src/core/constants.js +73 -0
  23. package/src/modules/Animator.js +244 -0
  24. package/src/modules/AutoRotate.js +43 -0
  25. package/src/modules/EventHandler.js +390 -0
  26. package/src/modules/Navigator.js +116 -0
  27. package/src/modules/UIManager.js +170 -0
  28. package/src/peek-carousel.d.ts +34 -0
  29. package/src/styles/base/_accessibility.scss +16 -0
  30. package/src/styles/base/_mixins.scss +7 -0
  31. package/src/styles/base/_variables.scss +75 -0
  32. package/src/styles/components/_carousel.scss +5 -0
  33. package/src/styles/components/_counter.scss +109 -0
  34. package/src/styles/components/_indicators.scss +154 -0
  35. package/src/styles/components/_navigation.scss +193 -0
  36. package/src/styles/components/carousel/_carousel-base.scss +99 -0
  37. package/src/styles/components/carousel/_carousel-classic.scss +76 -0
  38. package/src/styles/components/carousel/_carousel-mixins.scss +18 -0
  39. package/src/styles/components/carousel/_carousel-radial.scss +72 -0
  40. package/src/styles/components/carousel/_carousel-stack.scss +84 -0
  41. package/src/styles/components/carousel/_carousel-variables.scss +118 -0
  42. package/src/styles/peek-carousel.scss +11 -0
  43. package/src/utils/dom.js +53 -0
  44. package/src/utils/helpers.js +46 -0
  45. package/src/utils/icons.js +92 -0
  46. package/src/utils/preloader.js +69 -0
  47. package/types/index.d.ts +34 -0
@@ -0,0 +1,1376 @@
1
+ /**
2
+ * PeekCarousel - Peek 효과를 가진 캐러셀
3
+ * @version 1.0.0
4
+ * @license MIT
5
+ * @author lledellebell
6
+ */
7
+ (function (global, factory) {
8
+ typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
9
+ typeof define === 'function' && define.amd ? define(factory) :
10
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.PeekCarousel = factory());
11
+ })(this, (function () { 'use strict';
12
+
13
+ const LAYOUT_MODES = Object.freeze({
14
+ STACK: 'stack',
15
+ RADIAL: 'radial',
16
+ CLASSIC: 'classic'
17
+ });
18
+ const DEFAULT_OPTIONS = Object.freeze({
19
+ startIndex: 1,
20
+ layoutMode: LAYOUT_MODES.STACK,
21
+ autoRotate: false,
22
+ autoRotateInterval: 2500,
23
+ preloadRange: 2,
24
+ swipeThreshold: 50,
25
+ dragThreshold: 80,
26
+ enableKeyboard: true,
27
+ enableWheel: true,
28
+ enableTouch: true,
29
+ enableMouse: true,
30
+ showNavigation: true,
31
+ showCounter: true,
32
+ showIndicators: true,
33
+ showAutoRotateButton: true
34
+ });
35
+ function validateOptions(options) {
36
+ const validated = {
37
+ ...DEFAULT_OPTIONS,
38
+ ...options
39
+ };
40
+ if (validated.startIndex < 0) {
41
+ console.warn('PeekCarousel: startIndex는 0 이상이어야 합니다. 기본값 1 사용');
42
+ validated.startIndex = 1;
43
+ }
44
+ if (!Object.values(LAYOUT_MODES).includes(validated.layoutMode)) {
45
+ console.warn(`PeekCarousel: 유효하지 않은 layoutMode "${validated.layoutMode}". 기본값 "stack" 사용`);
46
+ validated.layoutMode = LAYOUT_MODES.STACK;
47
+ }
48
+ if (validated.autoRotateInterval < 100) {
49
+ console.warn('PeekCarousel: autoRotateInterval은 100ms 이상이어야 합니다. 기본값 2500 사용');
50
+ validated.autoRotateInterval = 2500;
51
+ }
52
+ if (validated.preloadRange < 0) {
53
+ console.warn('PeekCarousel: preloadRange는 0 이상이어야 합니다. 기본값 2 사용');
54
+ validated.preloadRange = 2;
55
+ }
56
+ return validated;
57
+ }
58
+
59
+ const CLASS_NAMES = Object.freeze({
60
+ carousel: 'peek-carousel',
61
+ track: 'peek-carousel__track',
62
+ item: 'peek-carousel__item',
63
+ itemActive: 'peek-carousel__item--active',
64
+ itemPrev: 'peek-carousel__item--prev',
65
+ itemNext: 'peek-carousel__item--next',
66
+ itemCenter: 'peek-carousel__item--center',
67
+ itemHidden: 'peek-carousel__item--hidden',
68
+ itemDraggingLeft: 'peek-carousel__item--dragging-left',
69
+ itemDraggingRight: 'peek-carousel__item--dragging-right',
70
+ figure: 'peek-carousel__figure',
71
+ image: 'peek-carousel__image',
72
+ caption: 'peek-carousel__caption',
73
+ nav: 'peek-carousel__nav',
74
+ navBtn: 'nav-btn',
75
+ btn: 'peek-carousel__btn',
76
+ prevBtn: 'prev-btn',
77
+ nextBtn: 'next-btn',
78
+ autoRotateBtn: 'auto-rotate-btn',
79
+ btnAutoRotate: 'peek-carousel__btn--auto-rotate',
80
+ btnActive: 'peek-carousel__btn--active',
81
+ controls: 'peek-carousel__controls',
82
+ indicators: 'peek-carousel__indicators',
83
+ indicator: 'indicator',
84
+ indicatorPeek: 'peek-carousel__indicator',
85
+ indicatorActive: 'peek-carousel__indicator--active',
86
+ indicatorProgress: 'peek-carousel__indicator--progress',
87
+ indicatorCompleted: 'peek-carousel__indicator--completed',
88
+ counter: 'peek-carousel__counter',
89
+ counterCurrent: 'peek-carousel__counter-current',
90
+ counterSeparator: 'peek-carousel__counter-separator',
91
+ counterTotal: 'peek-carousel__counter-total',
92
+ playIcon: 'play-icon',
93
+ pauseIcon: 'pause-icon'
94
+ });
95
+ const SELECTORS = Object.freeze({
96
+ carousel: '.peek-carousel__track',
97
+ item: '.peek-carousel__item',
98
+ indicator: '.indicator',
99
+ prevBtn: '.prev-btn',
100
+ nextBtn: '.next-btn',
101
+ autoRotateBtn: '.auto-rotate-btn',
102
+ playIcon: '.play-icon',
103
+ pauseIcon: '.pause-icon',
104
+ image: 'img'
105
+ });
106
+ const ARIA = Object.freeze({
107
+ current: 'aria-current',
108
+ selected: 'aria-selected',
109
+ pressed: 'aria-pressed',
110
+ label: 'aria-label',
111
+ tabindex: 'tabindex'
112
+ });
113
+ const BREAKPOINTS = Object.freeze({
114
+ mobile: 768 // [개발참고] px
115
+ });
116
+ const DURATIONS = Object.freeze({
117
+ transition: 500,
118
+ // [개발참고] ms
119
+ progressReset: 10 // [개발참고] ms
120
+ });
121
+ const KEYS = Object.freeze({
122
+ arrowLeft: 'ArrowLeft',
123
+ arrowRight: 'ArrowRight',
124
+ home: 'Home',
125
+ end: 'End',
126
+ space: ' '
127
+ });
128
+
129
+ function getElement(selector) {
130
+ if (typeof selector === 'string') {
131
+ return document.querySelector(selector);
132
+ }
133
+ return selector instanceof HTMLElement ? selector : null;
134
+ }
135
+ function getElements(selector, parent = document) {
136
+ if (typeof selector !== 'string') return [];
137
+ return Array.from(parent.querySelectorAll(selector));
138
+ }
139
+ function addClass(element, ...classes) {
140
+ if (element instanceof HTMLElement && classes.length > 0) {
141
+ element.classList.add(...classes);
142
+ }
143
+ }
144
+ function removeClass(element, ...classes) {
145
+ if (element instanceof HTMLElement && classes.length > 0) {
146
+ element.classList.remove(...classes);
147
+ }
148
+ }
149
+ function setCSSVar(element, property, value) {
150
+ if (element instanceof HTMLElement && property) {
151
+ element.style.setProperty(property, value);
152
+ }
153
+ }
154
+ function setAttribute(element, name, value) {
155
+ if (element instanceof HTMLElement && name) {
156
+ element.setAttribute(name, value);
157
+ }
158
+ }
159
+
160
+ const loadingCache = new Map();
161
+ function preloadImage(src) {
162
+ return new Promise((resolve, reject) => {
163
+ if (!src) {
164
+ reject(new Error('이미지 소스가 제공되지 않았습니다'));
165
+ return;
166
+ }
167
+ if (loadingCache.has(src)) {
168
+ return loadingCache.get(src);
169
+ }
170
+ const img = new Image();
171
+ const promise = new Promise((res, rej) => {
172
+ img.onload = () => {
173
+ loadingCache.delete(src);
174
+ res(img);
175
+ };
176
+ img.onerror = () => {
177
+ loadingCache.delete(src);
178
+ rej(new Error(`이미지 로드 실패: ${src}`));
179
+ };
180
+ img.src = src;
181
+ });
182
+ loadingCache.set(src, promise);
183
+ promise.then(resolve, reject);
184
+ });
185
+ }
186
+ function preloadImages(sources) {
187
+ if (!sources || sources.length === 0) {
188
+ return Promise.resolve([]);
189
+ }
190
+ const uniqueSources = [...new Set(sources)];
191
+ return Promise.all(uniqueSources.map(src => preloadImage(src)));
192
+ }
193
+ function preloadImagesInRange(items, currentIndex, range) {
194
+ if (!items || items.length === 0 || range < 0) {
195
+ return;
196
+ }
197
+ const totalItems = items.length;
198
+ const imagesToPreload = new Set();
199
+ for (let distance = 1; distance <= range; distance++) {
200
+ const prevIndex = (currentIndex - distance + totalItems) % totalItems;
201
+ const nextIndex = (currentIndex + distance) % totalItems;
202
+ const prevImg = items[prevIndex]?.querySelector('img');
203
+ const nextImg = items[nextIndex]?.querySelector('img');
204
+ if (prevImg && prevImg.src && !prevImg.complete) {
205
+ imagesToPreload.add(prevImg.src);
206
+ }
207
+ if (nextImg && nextImg.src && !nextImg.complete) {
208
+ imagesToPreload.add(nextImg.src);
209
+ }
210
+ }
211
+ if (imagesToPreload.size > 0) {
212
+ preloadImages([...imagesToPreload]).catch(err => {
213
+ console.warn('일부 이미지 프리로드 실패:', err);
214
+ });
215
+ }
216
+ }
217
+
218
+ const iconCache = new Map();
219
+ function createSVGIcon(path, options = {}) {
220
+ const {
221
+ width = 24,
222
+ height = 24,
223
+ viewBox = '0 0 24 24',
224
+ fill = 'none',
225
+ stroke = 'currentColor',
226
+ strokeWidth = 2,
227
+ strokeLinecap = 'round',
228
+ strokeLinejoin = 'round',
229
+ className = ''
230
+ } = options;
231
+ const cacheKey = `${path}-${JSON.stringify(options)}`;
232
+ if (iconCache.has(cacheKey)) {
233
+ return iconCache.get(cacheKey);
234
+ }
235
+ const svg = `<svg width="${width}" height="${height}" viewBox="${viewBox}" fill="${fill}" xmlns="http://www.w3.org/2000/svg" class="${className}"><path d="${path}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linecap="${strokeLinecap}" stroke-linejoin="${strokeLinejoin}"/></svg>`;
236
+ iconCache.set(cacheKey, svg);
237
+ return svg;
238
+ }
239
+ const ICONS = {
240
+ prev: {
241
+ path: 'M15 18L9 12L15 6',
242
+ options: {}
243
+ },
244
+ next: {
245
+ path: 'M9 18L15 12L9 6',
246
+ options: {}
247
+ },
248
+ play: {
249
+ path: 'M8 6.5v11l9-5.5z',
250
+ options: {
251
+ fill: 'currentColor',
252
+ stroke: 'currentColor',
253
+ strokeWidth: 0,
254
+ strokeLinecap: 'round',
255
+ strokeLinejoin: 'round'
256
+ }
257
+ },
258
+ pause: {
259
+ path: 'M7 5.5C7 5.22386 7.22386 5 7.5 5H9.5C9.77614 5 10 5.22386 10 5.5V18.5C10 18.7761 9.77614 19 9.5 19H7.5C7.22386 19 7 18.7761 7 18.5V5.5ZM14 5.5C14 5.22386 14.2239 5 14.5 5H16.5C16.7761 5 17 5.22386 17 5.5V18.5C17 18.7761 16.7761 19 16.5 19H14.5C14.2239 19 14 18.7761 14 18.5V5.5Z',
260
+ options: {
261
+ fill: 'currentColor',
262
+ stroke: 'none'
263
+ }
264
+ }
265
+ };
266
+ function getIcon(iconName, customOptions = {}) {
267
+ const icon = ICONS[iconName];
268
+ if (!icon) {
269
+ console.warn(`PeekCarousel: 아이콘 "${iconName}"을 찾을 수 없습니다`);
270
+ return '';
271
+ }
272
+ const options = {
273
+ ...icon.options,
274
+ ...customOptions
275
+ };
276
+ return createSVGIcon(icon.path, options);
277
+ }
278
+ function injectIcon(button, iconName, options = {}) {
279
+ if (!button || !(button instanceof HTMLElement)) return;
280
+ if (button.querySelector('svg')) {
281
+ return;
282
+ }
283
+ const iconHTML = getIcon(iconName, options);
284
+ if (iconHTML) {
285
+ button.innerHTML = iconHTML;
286
+ }
287
+ }
288
+ function injectAutoRotateIcons(button) {
289
+ if (!button || !(button instanceof HTMLElement)) return;
290
+ if (button.querySelector('svg')) {
291
+ return;
292
+ }
293
+ const playHTML = getIcon('play', {
294
+ className: 'play-icon'
295
+ });
296
+ const pauseHTML = getIcon('pause', {
297
+ className: 'pause-icon'
298
+ });
299
+ if (playHTML && pauseHTML) {
300
+ button.innerHTML = playHTML + pauseHTML;
301
+ }
302
+ }
303
+
304
+ function isMobile() {
305
+ return window.innerWidth <= 768;
306
+ }
307
+ function normalizeIndex(index, length) {
308
+ if (length <= 0) return 0;
309
+ return (index % length + length) % length;
310
+ }
311
+
312
+ const PROXIMITY_THRESHOLD = 2;
313
+ class Navigator {
314
+ constructor(carousel) {
315
+ this.carousel = carousel;
316
+ }
317
+ get currentIndex() {
318
+ return this.carousel.state.currentIndex;
319
+ }
320
+ set currentIndex(value) {
321
+ this.carousel.state.currentIndex = normalizeIndex(value, this.carousel.totalItems);
322
+ }
323
+ getShortestDistance(from, to) {
324
+ const total = this.carousel.totalItems;
325
+ const normalizedTo = normalizeIndex(to, total);
326
+ const normalizedFrom = normalizeIndex(from, total);
327
+ const forwardDist = (normalizedTo - normalizedFrom + total) % total;
328
+ const backwardDist = (normalizedFrom - normalizedTo + total) % total;
329
+ return forwardDist <= backwardDist ? forwardDist : -backwardDist;
330
+ }
331
+ isNearby(from, to) {
332
+ const distance = Math.abs(this.getShortestDistance(from, to));
333
+ return distance <= PROXIMITY_THRESHOLD;
334
+ }
335
+ updateAfterNavigation() {
336
+ this.carousel.animator.updateCarousel();
337
+ this.carousel.updateCounter();
338
+ if (this.carousel.options.preloadRange > 0) {
339
+ this.carousel.preloadImages();
340
+ }
341
+ }
342
+ rotate(direction) {
343
+ this.currentIndex = this.currentIndex + direction;
344
+ this.updateAfterNavigation();
345
+ }
346
+ next() {
347
+ this.rotate(1);
348
+ }
349
+ prev() {
350
+ this.rotate(-1);
351
+ }
352
+ goTo(index) {
353
+ const normalizedIndex = normalizeIndex(index, this.carousel.totalItems);
354
+ if (normalizedIndex === this.currentIndex) return;
355
+ this.currentIndex = normalizedIndex;
356
+ this.updateAfterNavigation();
357
+ }
358
+ navigateIfDifferent(targetIndex, callback) {
359
+ const normalizedIndex = normalizeIndex(targetIndex, this.carousel.totalItems);
360
+ if (normalizedIndex === this.currentIndex) return false;
361
+ callback(normalizedIndex);
362
+ return true;
363
+ }
364
+ handleItemClick(index) {
365
+ this.navigateIfDifferent(index, normalizedIndex => {
366
+ const {
367
+ layoutMode
368
+ } = this.carousel.options;
369
+ if (layoutMode === 'radial') {
370
+ this.handleRadialItemClick(normalizedIndex);
371
+ } else {
372
+ this.handleStackItemClick(normalizedIndex);
373
+ }
374
+ });
375
+ }
376
+ handleRadialItemClick(normalizedIndex) {
377
+ const shortestDist = this.getShortestDistance(this.currentIndex, normalizedIndex);
378
+ if (Math.abs(shortestDist) > 1) {
379
+ const direction = shortestDist > 0 ? 1 : -1;
380
+ this.rotate(direction);
381
+ } else {
382
+ this.rotate(shortestDist);
383
+ }
384
+ }
385
+ handleStackItemClick(normalizedIndex) {
386
+ if (this.isNearby(this.currentIndex, normalizedIndex)) {
387
+ const shortestDist = this.getShortestDistance(this.currentIndex, normalizedIndex);
388
+ this.rotate(shortestDist);
389
+ } else {
390
+ this.goTo(normalizedIndex);
391
+ }
392
+ }
393
+ handleIndicatorClick(index) {
394
+ this.navigateIfDifferent(index, normalizedIndex => {
395
+ const {
396
+ layoutMode
397
+ } = this.carousel.options;
398
+ if (layoutMode === 'radial') {
399
+ const shortestDist = this.getShortestDistance(this.currentIndex, normalizedIndex);
400
+ this.rotate(shortestDist);
401
+ } else {
402
+ this.goTo(normalizedIndex);
403
+ }
404
+ });
405
+ }
406
+ }
407
+
408
+ const RADIAL_RADIUS = 400;
409
+ const CLASSIC_POSITIONS = Object.freeze({
410
+ center: {
411
+ x: 50,
412
+ scale: 1
413
+ },
414
+ peek: {
415
+ scale: 1
416
+ },
417
+ hidden: {
418
+ scale: 0.85
419
+ }
420
+ });
421
+ const CLASSIC_SPACING = Object.freeze({
422
+ gapPercent: 5,
423
+ additionalMobile: 40,
424
+ additionalDesktop: 15,
425
+ mobileBreakpoint: 768
426
+ });
427
+ const MOMENTUM_CONFIG = Object.freeze({
428
+ friction: 0.92,
429
+ minVelocity: 0.05,
430
+ navigationThreshold: 1.5,
431
+ dampingFactor: 0.6
432
+ });
433
+ const STACK_POSITION_CLASSES = [CLASS_NAMES.itemCenter, CLASS_NAMES.itemPrev, CLASS_NAMES.itemNext, CLASS_NAMES.itemHidden];
434
+ class Animator {
435
+ constructor(carousel) {
436
+ this.carousel = carousel;
437
+ this.momentumAnimation = null;
438
+ this.isAnimating = false;
439
+ }
440
+ normalizeAngleDiff(diff) {
441
+ return (diff + 180) % 360 - 180;
442
+ }
443
+ round(value, decimals = 2) {
444
+ return Math.round(value * 10 ** decimals) / 10 ** decimals;
445
+ }
446
+ getAdjacentIndices(currentIndex) {
447
+ return {
448
+ prev: normalizeIndex(currentIndex - 1, this.carousel.totalItems),
449
+ next: normalizeIndex(currentIndex + 1, this.carousel.totalItems)
450
+ };
451
+ }
452
+ setCarouselRotation(angle) {
453
+ const rounded = this.round(angle, 2);
454
+ this.carousel.container.style.setProperty('--carousel-rotation', `${rounded}deg`);
455
+ }
456
+ setCSSVariables(element, variables) {
457
+ for (const [key, value] of Object.entries(variables)) {
458
+ element.style.setProperty(key, value);
459
+ }
460
+ }
461
+ updateRadialRotation(currentIndex) {
462
+ const targetAngle = -this.carousel.state.angleUnit * currentIndex;
463
+ const currentRotation = this.carousel.container.style.getPropertyValue('--carousel-rotation');
464
+ if (!currentRotation || currentRotation === '0deg') {
465
+ this.setCarouselRotation(targetAngle);
466
+ return;
467
+ }
468
+
469
+ // [개발참고] 최단 경로 계산: -180 ~ 180 범위로 정규화
470
+ const currentAngle = parseFloat(currentRotation);
471
+ const diff = this.normalizeAngleDiff(targetAngle - currentAngle);
472
+ const finalAngle = currentAngle + diff;
473
+ this.setCarouselRotation(finalAngle);
474
+ }
475
+ updateCarousel() {
476
+ const {
477
+ currentIndex
478
+ } = this.carousel.state;
479
+ const {
480
+ layoutMode
481
+ } = this.carousel.options;
482
+ if (layoutMode === 'stack' || layoutMode === 'classic') {
483
+ this.setCarouselRotation(0);
484
+ } else if (layoutMode === 'radial') {
485
+ this.updateRadialRotation(currentIndex);
486
+ }
487
+ this.updateActiveItem();
488
+ }
489
+ updateActiveItem() {
490
+ const {
491
+ currentIndex
492
+ } = this.carousel.state;
493
+ const {
494
+ layoutMode
495
+ } = this.carousel.options;
496
+ this.carousel.ui.updateActiveStates(currentIndex);
497
+ if (layoutMode === 'radial') {
498
+ this.updateRadialPositions(currentIndex);
499
+ } else if (layoutMode === 'classic') {
500
+ this.updateClassicPositions(currentIndex);
501
+ } else {
502
+ this.updateStackPositions(currentIndex);
503
+ }
504
+ }
505
+ updateRadialPositions(currentIndex) {
506
+ const {
507
+ angleUnit
508
+ } = this.carousel.state;
509
+ for (let i = 0; i < this.carousel.items.length; i++) {
510
+ const item = this.carousel.items[i];
511
+ const angle = angleUnit * i;
512
+ this.setCSSVariables(item, {
513
+ '--item-angle': `${this.round(angle, 2)}deg`,
514
+ '--item-radius': `${RADIAL_RADIUS}px`
515
+ });
516
+ }
517
+ const {
518
+ prev,
519
+ next
520
+ } = this.getAdjacentIndices(currentIndex);
521
+ this.carousel.ui.setPeekItems(prev, next);
522
+ }
523
+ updateStackPositions(currentIndex) {
524
+ const {
525
+ prev,
526
+ next
527
+ } = this.getAdjacentIndices(currentIndex);
528
+ for (let i = 0; i < this.carousel.items.length; i++) {
529
+ const item = this.carousel.items[i];
530
+ item.classList.remove(...STACK_POSITION_CLASSES);
531
+ if (i === currentIndex) {
532
+ item.classList.add(CLASS_NAMES.itemCenter);
533
+ } else if (i === prev) {
534
+ item.classList.add(CLASS_NAMES.itemPrev);
535
+ } else if (i === next) {
536
+ item.classList.add(CLASS_NAMES.itemNext);
537
+ } else {
538
+ item.classList.add(CLASS_NAMES.itemHidden);
539
+ }
540
+ }
541
+ }
542
+ calculateClassicSpacing(containerWidth) {
543
+ const itemWidth = Math.max(300, Math.min(containerWidth * 0.35, 500));
544
+ const isMobile = containerWidth <= CLASSIC_SPACING.mobileBreakpoint;
545
+ const itemHalfPercent = itemWidth / containerWidth * 50;
546
+ const baseSpacing = itemHalfPercent + CLASSIC_SPACING.gapPercent;
547
+ const additionalSpacing = isMobile ? CLASSIC_SPACING.additionalMobile : CLASSIC_SPACING.additionalDesktop;
548
+ return baseSpacing + additionalSpacing;
549
+ }
550
+ getClassicItemPosition(itemIndex, currentIndex, itemSpacing) {
551
+ const {
552
+ prev,
553
+ next
554
+ } = this.getAdjacentIndices(currentIndex);
555
+ if (itemIndex === currentIndex) {
556
+ return {
557
+ x: CLASSIC_POSITIONS.center.x,
558
+ scale: CLASSIC_POSITIONS.center.scale
559
+ };
560
+ }
561
+ if (itemIndex === prev) {
562
+ return {
563
+ x: CLASSIC_POSITIONS.center.x - itemSpacing,
564
+ scale: CLASSIC_POSITIONS.peek.scale
565
+ };
566
+ }
567
+ if (itemIndex === next) {
568
+ return {
569
+ x: CLASSIC_POSITIONS.center.x + itemSpacing,
570
+ scale: CLASSIC_POSITIONS.peek.scale
571
+ };
572
+ }
573
+ const distanceFromCurrent = itemIndex - currentIndex;
574
+ return {
575
+ x: distanceFromCurrent < 0 ? CLASSIC_POSITIONS.center.x - itemSpacing * 2 : CLASSIC_POSITIONS.center.x + itemSpacing * 2,
576
+ scale: CLASSIC_POSITIONS.hidden.scale
577
+ };
578
+ }
579
+ updateClassicPositions(currentIndex) {
580
+ const {
581
+ prev,
582
+ next
583
+ } = this.getAdjacentIndices(currentIndex);
584
+ const containerWidth = this.carousel.container.offsetWidth;
585
+ const itemSpacing = this.calculateClassicSpacing(containerWidth);
586
+ for (let i = 0; i < this.carousel.items.length; i++) {
587
+ const item = this.carousel.items[i];
588
+ const {
589
+ x,
590
+ scale
591
+ } = this.getClassicItemPosition(i, currentIndex, itemSpacing);
592
+ this.setCSSVariables(item, {
593
+ '--item-x': `${this.round(x, 2)}%`,
594
+ '--item-scale': String(scale)
595
+ });
596
+ }
597
+ this.carousel.ui.setPeekItems(prev, next);
598
+ }
599
+ startMomentum(velocity) {
600
+ this.stopMomentum();
601
+ let currentVelocity = velocity;
602
+ const momentumStep = () => {
603
+ currentVelocity *= MOMENTUM_CONFIG.friction;
604
+ if (Math.abs(currentVelocity) < MOMENTUM_CONFIG.minVelocity) {
605
+ this.stopMomentum();
606
+ return;
607
+ }
608
+ if (Math.abs(currentVelocity) > MOMENTUM_CONFIG.navigationThreshold) {
609
+ const direction = currentVelocity > 0 ? -1 : 1;
610
+ this.carousel.navigator.rotate(direction);
611
+ currentVelocity *= MOMENTUM_CONFIG.dampingFactor;
612
+ }
613
+ this.momentumAnimation = requestAnimationFrame(momentumStep);
614
+ };
615
+ this.isAnimating = true;
616
+ this.momentumAnimation = requestAnimationFrame(momentumStep);
617
+ }
618
+ stopMomentum() {
619
+ if (this.momentumAnimation) {
620
+ cancelAnimationFrame(this.momentumAnimation);
621
+ this.momentumAnimation = null;
622
+ }
623
+ this.isAnimating = false;
624
+ }
625
+ }
626
+
627
+ class AutoRotate {
628
+ constructor(carousel) {
629
+ this.carousel = carousel;
630
+ this.interval = null;
631
+ this.isActive = false;
632
+ }
633
+ setActiveState(isActive) {
634
+ this.isActive = isActive;
635
+ this.carousel.ui.updateAutoRotateButton(isActive);
636
+ }
637
+ toggle() {
638
+ this.isActive ? this.stop() : this.start();
639
+ }
640
+ start() {
641
+ if (this.isActive) return;
642
+ this.setActiveState(true);
643
+ const rotateInterval = this.carousel.options.autoRotateInterval;
644
+ this.interval = setInterval(() => {
645
+ this.carousel.navigator.next();
646
+ }, rotateInterval);
647
+ }
648
+ stop() {
649
+ if (!this.isActive) return;
650
+ this.setActiveState(false);
651
+ if (this.interval) {
652
+ clearInterval(this.interval);
653
+ this.interval = null;
654
+ }
655
+ }
656
+ destroy() {
657
+ this.stop();
658
+ this.carousel = null;
659
+ }
660
+ }
661
+
662
+ const WHEEL_CONFIG = Object.freeze({
663
+ threshold: 50,
664
+ timeout: 150,
665
+ cooldown: 100
666
+ });
667
+ const DRAG_CONFIG = Object.freeze({
668
+ touchThreshold: 15,
669
+ mouseThreshold: 10,
670
+ velocityThreshold: 0.5
671
+ });
672
+ const RESIZE_DEBOUNCE = 100;
673
+ class EventHandler {
674
+ constructor(carousel) {
675
+ this.carousel = carousel;
676
+ this.boundHandlers = new Map();
677
+ this.touch = {
678
+ startX: 0,
679
+ endX: 0
680
+ };
681
+ this.drag = {
682
+ active: false,
683
+ startX: 0,
684
+ currentX: 0,
685
+ lastX: 0,
686
+ lastTime: 0,
687
+ velocity: 0
688
+ };
689
+ this.wheel = {
690
+ isScrolling: false,
691
+ scrollTimeout: null,
692
+ lastWheelTime: 0,
693
+ accumulatedDelta: 0
694
+ };
695
+ }
696
+ init() {
697
+ this.initNavigationButtons();
698
+ this.initKeyboard();
699
+ this.initWheel();
700
+ this.initItemClick();
701
+ this.initIndicatorClick();
702
+ this.initTouch();
703
+ this.initMouse();
704
+ this.initResize();
705
+ }
706
+ stopAutoRotateAndNavigate(navigationFn) {
707
+ this.completeCurrentIndicator();
708
+ this.carousel.autoRotate.stop();
709
+ navigationFn();
710
+ }
711
+ completeCurrentIndicator() {
712
+ const currentIndicator = this.carousel.indicators[this.carousel.state.currentIndex];
713
+ if (currentIndicator && currentIndicator.classList.contains('peek-carousel__indicator--active')) {
714
+ currentIndicator.classList.add('peek-carousel__indicator--completed');
715
+ }
716
+ }
717
+ resetDragState(index) {
718
+ this.carousel.ui.removeDraggingClass(index);
719
+ this.carousel.ui.clearDragTransform();
720
+ }
721
+ updateDraggingClass(dragDistance, currentIndex, threshold) {
722
+ if (dragDistance > threshold) {
723
+ this.carousel.ui.addDraggingClass(currentIndex, 'right');
724
+ } else if (dragDistance < -threshold) {
725
+ this.carousel.ui.addDraggingClass(currentIndex, 'left');
726
+ }
727
+ }
728
+ initDragState(clientX) {
729
+ this.drag.active = true;
730
+ this.drag.startX = clientX;
731
+ this.drag.currentX = clientX;
732
+ this.drag.lastX = clientX;
733
+ this.drag.lastTime = Date.now();
734
+ this.drag.velocity = 0;
735
+ }
736
+ resetMouseCursor() {
737
+ this.carousel.elements.carousel.style.cursor = 'grab';
738
+ }
739
+ calculateWheelDelta(e) {
740
+ const deltaX = Math.abs(e.deltaX);
741
+ const deltaY = Math.abs(e.deltaY);
742
+ const isHorizontal = deltaX > deltaY;
743
+
744
+ // [개발참고] 수평: 왼쪽(-) = 다음, 오른쪽(+) = 이전
745
+ // 수직: 아래(+) = 다음, 위(-) = 이전
746
+ return isHorizontal ? -e.deltaX : e.deltaY;
747
+ }
748
+ resetWheelState() {
749
+ this.wheel.isScrolling = false;
750
+ this.wheel.accumulatedDelta = 0;
751
+ }
752
+ initNavigationButtons() {
753
+ const {
754
+ prevBtn,
755
+ nextBtn,
756
+ autoRotateBtn
757
+ } = this.carousel.elements;
758
+ if (prevBtn) {
759
+ this.addHandler(prevBtn, 'click', () => {
760
+ this.stopAutoRotateAndNavigate(() => this.carousel.navigator.prev());
761
+ });
762
+ }
763
+ if (nextBtn) {
764
+ this.addHandler(nextBtn, 'click', () => {
765
+ this.stopAutoRotateAndNavigate(() => this.carousel.navigator.next());
766
+ });
767
+ }
768
+ if (autoRotateBtn) {
769
+ this.addHandler(autoRotateBtn, 'click', () => {
770
+ this.carousel.autoRotate.toggle();
771
+ });
772
+ }
773
+ }
774
+ initKeyboard() {
775
+ if (!this.carousel.options.enableKeyboard) return;
776
+ const handler = e => {
777
+ const {
778
+ navigator,
779
+ autoRotate,
780
+ totalItems
781
+ } = this.carousel;
782
+ switch (e.key) {
783
+ case KEYS.arrowLeft:
784
+ autoRotate.stop();
785
+ navigator.prev();
786
+ break;
787
+ case KEYS.arrowRight:
788
+ autoRotate.stop();
789
+ navigator.next();
790
+ break;
791
+ case KEYS.home:
792
+ e.preventDefault();
793
+ autoRotate.stop();
794
+ navigator.goTo(0);
795
+ break;
796
+ case KEYS.end:
797
+ e.preventDefault();
798
+ autoRotate.stop();
799
+ navigator.goTo(totalItems - 1);
800
+ break;
801
+ case KEYS.space:
802
+ e.preventDefault();
803
+ autoRotate.toggle();
804
+ break;
805
+ default:
806
+ const numKey = parseInt(e.key);
807
+ if (numKey >= 1 && numKey <= totalItems) {
808
+ e.preventDefault();
809
+ autoRotate.stop();
810
+ navigator.goTo(numKey - 1);
811
+ }
812
+ }
813
+ };
814
+ this.addHandler(document, 'keydown', handler);
815
+ }
816
+ initWheel() {
817
+ if (!this.carousel.options.enableWheel) return;
818
+ const handler = e => {
819
+ const deltaX = Math.abs(e.deltaX);
820
+ const deltaY = Math.abs(e.deltaY);
821
+ if (deltaX < 1 && deltaY < 1) {
822
+ return;
823
+ }
824
+ if (deltaX === deltaY) {
825
+ return;
826
+ }
827
+ e.preventDefault();
828
+ const currentTime = Date.now();
829
+ if (currentTime - this.wheel.lastWheelTime < WHEEL_CONFIG.cooldown) {
830
+ return;
831
+ }
832
+ if (!this.wheel.isScrolling) {
833
+ this.wheel.isScrolling = true;
834
+ this.wheel.accumulatedDelta = 0;
835
+ this.carousel.autoRotate.stop();
836
+ this.carousel.animator.stopMomentum();
837
+ }
838
+ this.wheel.accumulatedDelta += this.calculateWheelDelta(e);
839
+ if (Math.abs(this.wheel.accumulatedDelta) >= WHEEL_CONFIG.threshold) {
840
+ const direction = this.wheel.accumulatedDelta > 0 ? 1 : -1;
841
+ this.carousel.navigator.rotate(direction);
842
+ this.wheel.accumulatedDelta = 0;
843
+ this.wheel.lastWheelTime = currentTime;
844
+ }
845
+ clearTimeout(this.wheel.scrollTimeout);
846
+ this.wheel.scrollTimeout = setTimeout(() => {
847
+ this.resetWheelState();
848
+ }, WHEEL_CONFIG.timeout);
849
+ };
850
+ this.addHandler(this.carousel.elements.carousel, 'wheel', handler, {
851
+ passive: false
852
+ });
853
+ }
854
+ initItemClick() {
855
+ const {
856
+ items
857
+ } = this.carousel;
858
+ for (let i = 0; i < items.length; i++) {
859
+ this.addHandler(items[i], 'click', () => {
860
+ this.carousel.autoRotate.stop();
861
+ this.carousel.navigator.handleItemClick(i);
862
+ });
863
+ }
864
+ }
865
+ initIndicatorClick() {
866
+ const {
867
+ indicators
868
+ } = this.carousel;
869
+ for (let i = 0; i < indicators.length; i++) {
870
+ this.addHandler(indicators[i], 'click', () => {
871
+ this.carousel.autoRotate.stop();
872
+ this.carousel.navigator.handleIndicatorClick(i);
873
+ });
874
+ }
875
+ }
876
+ initTouch() {
877
+ if (!this.carousel.options.enableTouch) return;
878
+ this.addHandler(this.carousel.elements.carousel, 'touchstart', e => {
879
+ this.touch.startX = e.changedTouches[0].screenX;
880
+ });
881
+ this.addHandler(this.carousel.elements.carousel, 'touchmove', e => {
882
+ const touchCurrentX = e.changedTouches[0].screenX;
883
+ const dragDistance = touchCurrentX - this.touch.startX;
884
+ const {
885
+ currentIndex
886
+ } = this.carousel.state;
887
+ this.carousel.ui.updateDragTransform(dragDistance);
888
+ this.updateDraggingClass(dragDistance, currentIndex, DRAG_CONFIG.touchThreshold);
889
+ });
890
+ this.addHandler(this.carousel.elements.carousel, 'touchend', e => {
891
+ this.touch.endX = e.changedTouches[0].screenX;
892
+ const swipeDistance = this.touch.endX - this.touch.startX;
893
+ const {
894
+ swipeThreshold
895
+ } = this.carousel.options;
896
+ const {
897
+ currentIndex
898
+ } = this.carousel.state;
899
+ this.resetDragState(currentIndex);
900
+ if (swipeDistance < -swipeThreshold) {
901
+ this.carousel.autoRotate.stop();
902
+ this.carousel.navigator.next();
903
+ } else if (swipeDistance > swipeThreshold) {
904
+ this.carousel.autoRotate.stop();
905
+ this.carousel.navigator.prev();
906
+ }
907
+ });
908
+ }
909
+ initMouse() {
910
+ if (!this.carousel.options.enableMouse) return;
911
+ this.addHandler(this.carousel.elements.carousel, 'mousedown', e => {
912
+ if (isMobile()) return;
913
+ this.initDragState(e.clientX);
914
+ this.carousel.autoRotate.stop();
915
+ this.carousel.animator.stopMomentum();
916
+ this.carousel.elements.carousel.style.cursor = 'grabbing';
917
+ e.preventDefault();
918
+ });
919
+ this.addHandler(document, 'mousemove', e => {
920
+ if (!this.drag.active) return;
921
+ const currentTime = Date.now();
922
+ const deltaTime = currentTime - this.drag.lastTime;
923
+ const deltaX = e.clientX - this.drag.lastX;
924
+ if (deltaTime > 0) {
925
+ this.drag.velocity = deltaX / deltaTime;
926
+ }
927
+ this.drag.currentX = e.clientX;
928
+ this.drag.lastX = e.clientX;
929
+ this.drag.lastTime = currentTime;
930
+ const dragDistance = this.drag.currentX - this.drag.startX;
931
+ const {
932
+ currentIndex
933
+ } = this.carousel.state;
934
+ this.carousel.ui.updateDragTransform(dragDistance);
935
+ this.updateDraggingClass(dragDistance, currentIndex, DRAG_CONFIG.mouseThreshold);
936
+ if (Math.abs(dragDistance) > this.carousel.options.dragThreshold) {
937
+ const direction = dragDistance > 0 ? -1 : 1;
938
+ this.carousel.navigator.rotate(direction);
939
+ this.drag.startX = this.drag.currentX;
940
+ this.resetDragState(currentIndex);
941
+ }
942
+ });
943
+ this.addHandler(document, 'mouseup', () => {
944
+ if (!this.drag.active) return;
945
+ this.drag.active = false;
946
+ this.resetMouseCursor();
947
+ const {
948
+ currentIndex
949
+ } = this.carousel.state;
950
+ this.resetDragState(currentIndex);
951
+ if (Math.abs(this.drag.velocity) > DRAG_CONFIG.velocityThreshold) {
952
+ this.carousel.animator.startMomentum(this.drag.velocity);
953
+ }
954
+ });
955
+ this.addHandler(this.carousel.elements.carousel, 'mouseleave', () => {
956
+ if (this.drag.active) {
957
+ this.drag.active = false;
958
+ this.resetMouseCursor();
959
+ const {
960
+ currentIndex
961
+ } = this.carousel.state;
962
+ this.resetDragState(currentIndex);
963
+ }
964
+ });
965
+ if (window.innerWidth > BREAKPOINTS.mobile) {
966
+ this.resetMouseCursor();
967
+ }
968
+ }
969
+ initResize() {
970
+ let resizeTimer;
971
+ const handler = () => {
972
+ clearTimeout(resizeTimer);
973
+ resizeTimer = setTimeout(() => {
974
+ this.carousel.animator.updateCarousel();
975
+ }, RESIZE_DEBOUNCE);
976
+ };
977
+ this.addHandler(window, 'resize', handler);
978
+ }
979
+ addHandler(element, event, handler, options) {
980
+ element.addEventListener(event, handler, options);
981
+ const key = `${event}-${Date.now()}-${Math.random()}`;
982
+ this.boundHandlers.set(key, {
983
+ element,
984
+ event,
985
+ handler,
986
+ options
987
+ });
988
+ }
989
+ destroy() {
990
+ if (this.wheel.scrollTimeout) {
991
+ clearTimeout(this.wheel.scrollTimeout);
992
+ this.wheel.scrollTimeout = null;
993
+ }
994
+ for (const {
995
+ element,
996
+ event,
997
+ handler,
998
+ options
999
+ } of this.boundHandlers.values()) {
1000
+ element.removeEventListener(event, handler, options);
1001
+ }
1002
+ this.boundHandlers.clear();
1003
+ this.carousel = null;
1004
+ }
1005
+ }
1006
+
1007
+ const DRAG_TRANSFORM_CONFIG = Object.freeze({
1008
+ stack: {
1009
+ maxDrag: 200,
1010
+ offsetMultiplier: 100,
1011
+ rotationMultiplier: 3
1012
+ },
1013
+ radial: {
1014
+ rotationSensitivity: 0.2
1015
+ },
1016
+ classic: {
1017
+ dragSensitivity: 0.5
1018
+ }
1019
+ });
1020
+ class UIManager {
1021
+ constructor(carousel) {
1022
+ this.carousel = carousel;
1023
+ }
1024
+ updateActiveStates(currentIndex) {
1025
+ for (let i = 0; i < this.carousel.items.length; i++) {
1026
+ const item = this.carousel.items[i];
1027
+ removeClass(item, CLASS_NAMES.itemActive, CLASS_NAMES.itemPrev, CLASS_NAMES.itemNext);
1028
+ item.removeAttribute(ARIA.current);
1029
+ }
1030
+ for (let i = 0; i < this.carousel.indicators.length; i++) {
1031
+ const indicator = this.carousel.indicators[i];
1032
+ removeClass(indicator, CLASS_NAMES.indicatorActive, CLASS_NAMES.indicatorProgress);
1033
+ setAttribute(indicator, ARIA.selected, 'false');
1034
+ setAttribute(indicator, ARIA.tabindex, '-1');
1035
+ }
1036
+ const currentItem = this.carousel.items[currentIndex];
1037
+ const currentIndicator = this.carousel.indicators[currentIndex];
1038
+ if (currentItem) {
1039
+ addClass(currentItem, CLASS_NAMES.itemActive);
1040
+ setAttribute(currentItem, ARIA.current, 'true');
1041
+ }
1042
+ if (currentIndicator) {
1043
+ removeClass(currentIndicator, CLASS_NAMES.indicatorCompleted);
1044
+ addClass(currentIndicator, CLASS_NAMES.indicatorActive);
1045
+ setAttribute(currentIndicator, ARIA.selected, 'true');
1046
+ setAttribute(currentIndicator, ARIA.tabindex, '0');
1047
+ if (this.carousel.autoRotate.isActive) {
1048
+ this.updateIndicatorProgress(currentIndicator);
1049
+ }
1050
+ }
1051
+ }
1052
+ updateIndicatorProgress(indicator) {
1053
+ setCSSVar(indicator, '--progress-duration', `${this.carousel.options.autoRotateInterval}ms`);
1054
+ setTimeout(() => {
1055
+ if (indicator) {
1056
+ addClass(indicator, CLASS_NAMES.indicatorProgress);
1057
+ }
1058
+ }, DURATIONS.progressReset);
1059
+ }
1060
+ clearPeekItems() {
1061
+ for (let i = 0; i < this.carousel.items.length; i++) {
1062
+ const item = this.carousel.items[i];
1063
+ removeClass(item, CLASS_NAMES.itemPrev, CLASS_NAMES.itemNext);
1064
+ }
1065
+ }
1066
+ setPeekItems(prevIndex, nextIndex) {
1067
+ const prevItem = this.carousel.items[prevIndex];
1068
+ const nextItem = this.carousel.items[nextIndex];
1069
+ if (prevItem) addClass(prevItem, CLASS_NAMES.itemPrev);
1070
+ if (nextItem) addClass(nextItem, CLASS_NAMES.itemNext);
1071
+ }
1072
+ updateAutoRotateButton(isActive) {
1073
+ const {
1074
+ autoRotateBtn
1075
+ } = this.carousel.elements;
1076
+ if (!autoRotateBtn) return;
1077
+ if (isActive) {
1078
+ addClass(autoRotateBtn, CLASS_NAMES.btnActive);
1079
+ setAttribute(autoRotateBtn, ARIA.pressed, 'true');
1080
+ } else {
1081
+ removeClass(autoRotateBtn, CLASS_NAMES.btnActive);
1082
+ setAttribute(autoRotateBtn, ARIA.pressed, 'false');
1083
+ }
1084
+ }
1085
+ addDraggingClass(index, direction) {
1086
+ const item = this.carousel.items[index];
1087
+ if (!item) return;
1088
+ const leftClass = CLASS_NAMES.itemDraggingLeft;
1089
+ const rightClass = CLASS_NAMES.itemDraggingRight;
1090
+ removeClass(item, leftClass, rightClass);
1091
+ if (direction === 'left') {
1092
+ addClass(item, leftClass);
1093
+ } else if (direction === 'right') {
1094
+ addClass(item, rightClass);
1095
+ }
1096
+ }
1097
+ removeDraggingClass(index) {
1098
+ const item = this.carousel.items[index];
1099
+ if (!item) return;
1100
+ removeClass(item, CLASS_NAMES.itemDraggingLeft, CLASS_NAMES.itemDraggingRight);
1101
+ }
1102
+ round(value, decimals = 2) {
1103
+ return Math.round(value * 10 ** decimals) / 10 ** decimals;
1104
+ }
1105
+ applyEasing(progress) {
1106
+ return progress * (2 - Math.abs(progress));
1107
+ }
1108
+ updateDragTransform(dragDistance) {
1109
+ const {
1110
+ layoutMode
1111
+ } = this.carousel.options;
1112
+ if (layoutMode === 'stack') {
1113
+ // [개발참고] Stack 모드: 탄성 효과 적용 (easeOutQuad)
1114
+ const config = DRAG_TRANSFORM_CONFIG.stack;
1115
+ const clampedDrag = Math.max(-config.maxDrag, Math.min(config.maxDrag, dragDistance));
1116
+ const progress = clampedDrag / config.maxDrag;
1117
+ const easedProgress = this.applyEasing(progress);
1118
+ const dragOffset = this.round(easedProgress * config.offsetMultiplier);
1119
+ const dragRotation = this.round(easedProgress * config.rotationMultiplier);
1120
+ this.carousel.container.style.setProperty('--drag-offset', `${dragOffset}px`);
1121
+ this.carousel.container.style.setProperty('--drag-rotation', `${dragRotation}deg`);
1122
+ } else if (layoutMode === 'radial') {
1123
+ const config = DRAG_TRANSFORM_CONFIG.radial;
1124
+ const dragRotation = this.round(dragDistance * config.rotationSensitivity);
1125
+ this.carousel.container.style.setProperty('--drag-rotation-y', `${dragRotation}deg`);
1126
+ } else if (layoutMode === 'classic') {
1127
+ const config = DRAG_TRANSFORM_CONFIG.classic;
1128
+ const dragOffset = this.round(dragDistance * config.dragSensitivity);
1129
+ this.carousel.container.style.setProperty('--drag-offset', `${dragOffset}px`);
1130
+ }
1131
+ }
1132
+ clearDragTransform() {
1133
+ this.carousel.container.style.setProperty('--drag-offset', '0px');
1134
+ this.carousel.container.style.setProperty('--drag-rotation', '0deg');
1135
+ this.carousel.container.style.setProperty('--drag-rotation-y', '0deg');
1136
+ }
1137
+ destroy() {}
1138
+ }
1139
+
1140
+ const FULL_CIRCLE_DEGREES = 360;
1141
+ class PeekCarousel {
1142
+ constructor(selector, options = {}) {
1143
+ this.container = getElement(selector);
1144
+ if (!this.container) {
1145
+ throw new Error(`PeekCarousel: 셀렉터 "${selector}"에 해당하는 컨테이너를 찾을 수 없습니다`);
1146
+ }
1147
+ this.options = validateOptions(options);
1148
+ this.initElements();
1149
+ if (this.items.length === 0) {
1150
+ throw new Error('PeekCarousel: 캐러셀 아이템을 찾을 수 없습니다');
1151
+ }
1152
+ this.state = {
1153
+ currentIndex: this.options.startIndex,
1154
+ angleUnit: FULL_CIRCLE_DEGREES / this.totalItems
1155
+ };
1156
+ this.initModules();
1157
+ this.init();
1158
+ }
1159
+ initElements() {
1160
+ this.elements = {
1161
+ carousel: this.container.querySelector(SELECTORS.carousel),
1162
+ prevBtn: null,
1163
+ nextBtn: null,
1164
+ autoRotateBtn: null,
1165
+ controls: null,
1166
+ nav: null
1167
+ };
1168
+ this.items = getElements(SELECTORS.item, this.container);
1169
+ this.totalItems = this.items.length;
1170
+ this.indicators = [];
1171
+ }
1172
+ initModules() {
1173
+ this.navigator = new Navigator(this);
1174
+ this.animator = new Animator(this);
1175
+ this.autoRotate = new AutoRotate(this);
1176
+ this.eventHandler = new EventHandler(this);
1177
+ this.ui = new UIManager(this);
1178
+ }
1179
+ init() {
1180
+ this.updateLayoutClass();
1181
+ this.createNavigation();
1182
+ this.createControls();
1183
+ this.injectIcons();
1184
+ this.createCounter();
1185
+ this.setImageLoadingAttributes();
1186
+ this.initCSSVariables();
1187
+ this.eventHandler.init();
1188
+ this.animator.updateCarousel();
1189
+ if (this.options.autoRotate) {
1190
+ this.autoRotate.start();
1191
+ }
1192
+ if (this.options.preloadRange > 0) {
1193
+ this.preloadImages();
1194
+ }
1195
+ }
1196
+ initCSSVariables() {
1197
+ this.container.style.setProperty('--carousel-rotation', '0deg');
1198
+ this.container.style.setProperty('--drag-offset', '0px');
1199
+ this.container.style.setProperty('--drag-rotation', '0deg');
1200
+ this.container.style.setProperty('--drag-rotation-y', '0deg');
1201
+ }
1202
+ createNavigation() {
1203
+ if (!this.options.showNavigation) return;
1204
+ const existingNav = this.container.querySelector(`.${CLASS_NAMES.nav}`);
1205
+ if (existingNav) {
1206
+ this.elements.nav = existingNav;
1207
+ this.elements.prevBtn = existingNav.querySelector(SELECTORS.prevBtn);
1208
+ this.elements.nextBtn = existingNav.querySelector(SELECTORS.nextBtn);
1209
+ return;
1210
+ }
1211
+ const nav = document.createElement('div');
1212
+ nav.className = CLASS_NAMES.nav;
1213
+ const prevBtn = document.createElement('button');
1214
+ prevBtn.className = `${CLASS_NAMES.navBtn} ${CLASS_NAMES.btn} ${CLASS_NAMES.prevBtn}`;
1215
+ prevBtn.setAttribute('aria-label', 'Previous');
1216
+ const nextBtn = document.createElement('button');
1217
+ nextBtn.className = `${CLASS_NAMES.navBtn} ${CLASS_NAMES.btn} ${CLASS_NAMES.nextBtn}`;
1218
+ nextBtn.setAttribute('aria-label', 'Next');
1219
+ nav.appendChild(prevBtn);
1220
+ nav.appendChild(nextBtn);
1221
+ this.container.appendChild(nav);
1222
+ this.elements.nav = nav;
1223
+ this.elements.prevBtn = prevBtn;
1224
+ this.elements.nextBtn = nextBtn;
1225
+ }
1226
+ createControls() {
1227
+ if (!this.options.showIndicators && !this.options.showAutoRotateButton) return;
1228
+ const existingControls = this.container.querySelector(`.${CLASS_NAMES.controls}`);
1229
+ if (existingControls) {
1230
+ this.elements.controls = existingControls;
1231
+ const indicatorsWrapper = existingControls.querySelector(`.${CLASS_NAMES.indicators}`);
1232
+ if (indicatorsWrapper && this.options.showIndicators) {
1233
+ indicatorsWrapper.innerHTML = '';
1234
+ this.createIndicators(indicatorsWrapper);
1235
+ }
1236
+ this.elements.autoRotateBtn = existingControls.querySelector(SELECTORS.autoRotateBtn);
1237
+ return;
1238
+ }
1239
+ const controls = document.createElement('div');
1240
+ controls.className = CLASS_NAMES.controls;
1241
+ if (this.options.showIndicators) {
1242
+ const indicatorsWrapper = document.createElement('div');
1243
+ indicatorsWrapper.className = CLASS_NAMES.indicators;
1244
+ this.createIndicators(indicatorsWrapper);
1245
+ controls.appendChild(indicatorsWrapper);
1246
+ }
1247
+ if (this.options.showAutoRotateButton) {
1248
+ const autoRotateBtn = document.createElement('button');
1249
+ autoRotateBtn.className = `${CLASS_NAMES.autoRotateBtn} ${CLASS_NAMES.btn} ${CLASS_NAMES.btnAutoRotate}`;
1250
+ autoRotateBtn.setAttribute('aria-label', 'Toggle auto-rotate');
1251
+ autoRotateBtn.setAttribute('aria-pressed', 'false');
1252
+ controls.appendChild(autoRotateBtn);
1253
+ this.elements.autoRotateBtn = autoRotateBtn;
1254
+ }
1255
+ this.container.appendChild(controls);
1256
+ this.elements.controls = controls;
1257
+ }
1258
+ createIndicators(wrapper) {
1259
+ this.indicators = [];
1260
+ for (let i = 0; i < this.totalItems; i++) {
1261
+ const indicator = document.createElement('button');
1262
+ const isActive = i === this.state.currentIndex;
1263
+ indicator.className = CLASS_NAMES.indicator;
1264
+ indicator.classList.add(CLASS_NAMES.indicatorPeek);
1265
+ indicator.setAttribute('role', 'tab');
1266
+ indicator.setAttribute('aria-label', `Image ${i + 1}`);
1267
+ indicator.setAttribute('aria-selected', isActive ? 'true' : 'false');
1268
+ indicator.setAttribute('tabindex', isActive ? '0' : '-1');
1269
+ if (isActive) {
1270
+ indicator.classList.add(CLASS_NAMES.indicatorActive);
1271
+ }
1272
+ wrapper.appendChild(indicator);
1273
+ this.indicators.push(indicator);
1274
+ }
1275
+ }
1276
+ injectIcons() {
1277
+ const {
1278
+ prevBtn,
1279
+ nextBtn,
1280
+ autoRotateBtn
1281
+ } = this.elements;
1282
+ if (prevBtn) injectIcon(prevBtn, 'prev');
1283
+ if (nextBtn) injectIcon(nextBtn, 'next');
1284
+ if (autoRotateBtn) injectAutoRotateIcons(autoRotateBtn);
1285
+ }
1286
+ createCounter() {
1287
+ if (!this.options.showCounter) return;
1288
+ const existingCounter = this.container.querySelector(`.${CLASS_NAMES.counter}`);
1289
+ if (existingCounter) {
1290
+ this.counterElement = existingCounter;
1291
+ this.updateCounter();
1292
+ return;
1293
+ }
1294
+ const counter = document.createElement('div');
1295
+ counter.className = CLASS_NAMES.counter;
1296
+ counter.setAttribute('aria-live', 'polite');
1297
+ counter.setAttribute('aria-atomic', 'true');
1298
+ counter.innerHTML = `
1299
+ <span class="${CLASS_NAMES.counterCurrent}">${this.state.currentIndex + 1}</span>
1300
+ <span class="${CLASS_NAMES.counterSeparator}">/</span>
1301
+ <span class="${CLASS_NAMES.counterTotal}">${this.totalItems}</span>
1302
+ `;
1303
+ this.container.appendChild(counter);
1304
+ this.counterElement = counter;
1305
+ }
1306
+ updateCounter() {
1307
+ if (!this.counterElement) return;
1308
+ const currentSpan = this.counterElement.querySelector(`.${CLASS_NAMES.counterCurrent}`);
1309
+ if (currentSpan) {
1310
+ currentSpan.textContent = this.state.currentIndex + 1;
1311
+ }
1312
+ }
1313
+ setImageLoadingAttributes() {
1314
+ const {
1315
+ startIndex
1316
+ } = this.options;
1317
+ const preloadRange = this.options.preloadRange || 1;
1318
+ for (let index = 0; index < this.items.length; index++) {
1319
+ const item = this.items[index];
1320
+ const img = item.querySelector(`.${CLASS_NAMES.image}`);
1321
+ if (!img || img.hasAttribute('loading')) continue;
1322
+ const distance = Math.abs(index - startIndex);
1323
+ const isNearby = distance <= preloadRange;
1324
+ img.setAttribute('loading', isNearby ? 'eager' : 'lazy');
1325
+ }
1326
+ }
1327
+ updateLayoutClass() {
1328
+ const currentMode = this.currentLayoutMode;
1329
+ const newMode = this.options.layoutMode;
1330
+ if (currentMode && currentMode !== newMode) {
1331
+ this.container.classList.remove(`peek-carousel--${currentMode}`);
1332
+ }
1333
+ this.container.classList.add(`peek-carousel--${newMode}`);
1334
+ this.currentLayoutMode = newMode;
1335
+ }
1336
+ preloadImages() {
1337
+ preloadImagesInRange(this.items, this.state.currentIndex, this.options.preloadRange);
1338
+ }
1339
+
1340
+ // [개발참고] Public API
1341
+ next() {
1342
+ this.navigator.next();
1343
+ }
1344
+ prev() {
1345
+ this.navigator.prev();
1346
+ }
1347
+ goTo(index) {
1348
+ this.navigator.goTo(index);
1349
+ }
1350
+ startAutoRotate() {
1351
+ this.autoRotate.start();
1352
+ }
1353
+ stopAutoRotate() {
1354
+ this.autoRotate.stop();
1355
+ }
1356
+ toggleAutoRotate() {
1357
+ this.autoRotate.toggle();
1358
+ }
1359
+ destroy() {
1360
+ this.autoRotate.destroy();
1361
+ this.animator.stopMomentum();
1362
+ this.eventHandler.destroy();
1363
+ this.ui.destroy();
1364
+ }
1365
+ get currentIndex() {
1366
+ return this.state.currentIndex;
1367
+ }
1368
+ get isAutoRotating() {
1369
+ return this.autoRotate.isActive;
1370
+ }
1371
+ }
1372
+
1373
+ return PeekCarousel;
1374
+
1375
+ }));
1376
+ //# sourceMappingURL=peek-carousel.js.map