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