srcdev-nuxt-components 4.0.2 → 4.0.4

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.
@@ -29,8 +29,12 @@
29
29
  </ul>
30
30
  </div>
31
31
  <div class="buttons-container">
32
- <button type="button" @click.prevent="actionPrevious()" class="btn-action" aria-label="Go to previous item">Prev</button>
33
- <button type="button" @click.prevent="actionNext()" class="btn-action" aria-label="Go to next item">Next</button>
32
+ <button type="button" @click.prevent="actionPrevious()" class="btn-action" aria-label="Go to previous item">
33
+ <Icon name="ic:outline-keyboard-arrow-left" class="arrows-icon" />
34
+ </button>
35
+ <button type="button" @click.prevent="actionNext()" class="btn-action" aria-label="Go to next item">
36
+ <Icon name="ic:outline-keyboard-arrow-right" class="arrows-icon" />
37
+ </button>
34
38
  </div>
35
39
  </div>
36
40
  </LayoutRow>
@@ -57,6 +61,10 @@ const props = defineProps({
57
61
  type: Boolean,
58
62
  default: false,
59
63
  },
64
+ returnToStart: {
65
+ type: Boolean,
66
+ default: false,
67
+ },
60
68
  });
61
69
 
62
70
  const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
@@ -72,12 +80,17 @@ const itemCount = ref(props.carouselDataIds.length);
72
80
  const offset = ref(0);
73
81
  const transitionSpeedStr = props.transitionSpeed + 'ms';
74
82
  const itemTransform = computed(() => {
75
- return `translateX(calc(${offset.value} * (${itemWidth.value} + var(--_item-gap))))`;
83
+ return `translateX(calc(${offset.value} * (${itemWidth.value} + var(--_carousel-item-track-gap))))`;
76
84
  });
77
85
 
78
86
  const itemWidth = ref('0px');
79
87
 
80
88
  const actionPrevious = () => {
89
+ if (props.returnToStart && currentIndex.value === 0) {
90
+ offset.value = -itemCount.value;
91
+ doAction();
92
+ }
93
+
81
94
  if (offset.value >= 0) {
82
95
  return;
83
96
  }
@@ -87,6 +100,12 @@ const actionPrevious = () => {
87
100
  };
88
101
 
89
102
  const actionNext = () => {
103
+ if (props.returnToStart && offset.value <= -1 * (itemCount.value - 1)) {
104
+ offset.value = 0;
105
+ doAction();
106
+ return;
107
+ }
108
+
90
109
  if (offset.value <= -1 * (itemCount.value - 1)) {
91
110
  return;
92
111
  }
@@ -152,7 +171,7 @@ onMounted(() => {
152
171
 
153
172
  <style lang="css">
154
173
  .carousel-basic {
155
- --_item-gap: 10px;
174
+ --_carousel-item-track-gap: 10px;
156
175
 
157
176
  display: grid;
158
177
  grid-template-columns: 1fr;
@@ -172,7 +191,7 @@ onMounted(() => {
172
191
 
173
192
  .timeline-container {
174
193
  display: flex;
175
- gap: var(--_item-gap);
194
+ gap: var(--_carousel-item-track-gap);
176
195
  overflow-x: hidden;
177
196
 
178
197
  .timeline-item {
@@ -200,7 +219,7 @@ onMounted(() => {
200
219
 
201
220
  .item-container {
202
221
  display: flex;
203
- gap: var(--_item-gap);
222
+ gap: var(--_carousel-item-track-gap);
204
223
  overflow-x: hidden;
205
224
  position: relative;
206
225
 
@@ -254,8 +273,17 @@ onMounted(() => {
254
273
  gap: 20px;
255
274
 
256
275
  .btn-action {
276
+ display: flex;
277
+ align-items: center;
278
+ justify-content: center;
279
+
257
280
  cursor: pointer;
258
281
  height: fit-content;
282
+
283
+ .arrows-icon {
284
+ width: 24px;
285
+ height: 24px;
286
+ }
259
287
  }
260
288
  }
261
289
  }
@@ -1,48 +1,57 @@
1
1
  <template>
2
- <section class="carousel-flip" :class="[elementClasses, { 'controls-inside': controlsInside }]">
3
-
4
- <div class="item-container" ref="carouselContent">
5
- <div v-for="(item, index) in data?.items" :key="index" class="item" ref="carouselItems">
6
- <h3>{{ index }}</h3>
7
- <p>{{ item.alt }}</p>
8
- </div>
9
-
10
- </div>
11
-
12
- <div class="controls-container">
13
- <div class="buttons-container">
14
- <button type="submit" @click.prevent="actionPrevious()" class="btn-action"
15
- :disabled="transitionRunning">Prev</button>
16
- <button type="submit" @click.prevent="actionNext()" class="btn-action"
17
- :disabled="transitionRunning">Next</button>
2
+ <section class="carousel-flip" :class="[elementClasses]" ref="carouselWrapperRef" role="region" aria-label="Image carousel">
3
+ <div aria-live="polite" aria-atomic="true" class="sr-only">Item {{ currentActiveIndex + 1 }} of {{ itemCount }}</div>
4
+
5
+ <LayoutRow tag="div" variant="full-width" :style-class-passthrough="['mbe-20']">
6
+ <div tabindex="0" class="item-container" :class="{ 'allow-overflow': allowCarouselOverflow }" ref="carouselContainerRef" role="group" aria-label="Carousel items">
7
+ <div
8
+ v-for="(item, index) in carouselDataIds"
9
+ :key="index"
10
+ class="item"
11
+ :class="{ loaded: carouselInitComplete && userHasInteracted }"
12
+ ref="carouselItems"
13
+ :data-id="item"
14
+ :aria-current="currentActiveIndex === index ? 'true' : 'false'"
15
+ >
16
+ <slot :name="item"></slot>
17
+ </div>
18
18
  </div>
19
- <div class="thumbnail-container">
20
- <ul class="thumbnail-list">
21
- <li v-for="item, index in data?.items" class="thumbnail-item" ref="thumbnailItems">
22
- <div class="thumbnail-item_inner">{{ index }}</div>
23
- </li>
24
- </ul>
19
+ </LayoutRow>
20
+
21
+ <LayoutRow tag="div" variant="full-width" :style-class-passthrough="['mbe-20']">
22
+ <div tabindex="0" class="controls-container" ref="controlsContainerRef">
23
+ <div class="markers-container">
24
+ <ul class="markers-list">
25
+ <li v-for="index in itemCount" :key="index" class="markers-item">
26
+ <button
27
+ @click.prevent="jumpToFrame(index - 1)"
28
+ class="btn-marker"
29
+ :class="[{ active: currentActiveIndex === getOffsetIndex(index - 1, circularOffsetBase, itemCount) }]"
30
+ :aria-label="`Jump to item ${Math.floor(index + 1)}`"
31
+ ></button>
32
+ </li>
33
+ </ul>
34
+ </div>
35
+ <div class="buttons-container">
36
+ <button type="button" @click.prevent="actionPrevious()" class="btn-action" aria-label="Go to previous item">
37
+ <Icon name="ic:outline-keyboard-arrow-left" class="arrows-icon" />
38
+ </button>
39
+ <button type="button" @click.prevent="actionNext()" class="btn-action" aria-label="Go to next item">
40
+ <Icon name="ic:outline-keyboard-arrow-right" class="arrows-icon" />
41
+ </button>
42
+ </div>
25
43
  </div>
26
- </div>
44
+ </LayoutRow>
27
45
  </section>
28
46
  </template>
29
47
 
30
48
  <script setup lang="ts">
31
- import type { ICarouselBasic } from "@/types/types.carousel-basic";
32
- import { useElementSize, useEventListener, useResizeObserver } from "@vueuse/core";
49
+ import { useEventListener, useResizeObserver, useSwipe } from '@vueuse/core';
50
+
33
51
  const props = defineProps({
34
- propsData: {
35
- type: Object as PropType<ICarouselBasic>,
36
- default: <ICarouselBasic>{
37
- items: [],
38
- total: 0,
39
- skip: 0,
40
- limit: 10
41
- }
42
- },
43
- data: {
44
- type: Object,
45
- default: <ICarouselBasic>{}
52
+ carouselDataIds: {
53
+ type: Array as PropType<string[]>,
54
+ default: () => [],
46
55
  },
47
56
  styleClassPassthrough: {
48
57
  type: Array as PropType<string[]>,
@@ -50,329 +59,403 @@ const props = defineProps({
50
59
  },
51
60
  transitionSpeed: {
52
61
  type: Number,
53
- default: 1000
62
+ default: 200,
54
63
  },
55
- controlsInside: {
64
+ allowCarouselOverflow: {
56
65
  type: Boolean,
57
- default: false
58
- }
66
+ default: false,
67
+ },
68
+ useFlipAnimation: {
69
+ type: Boolean,
70
+ default: true,
71
+ },
59
72
  });
60
73
 
61
- const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
74
+ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
62
75
 
63
- const carouselContentRef = useTemplateRef<HTMLDivElement>('carouselContent');
64
- const carouselItems = useTemplateRef<HTMLDivElement[]>('carouselItems');
65
- const thumbnailItems = useTemplateRef<HTMLLIElement[]>('thumbnailItems');
76
+ const carouselWrapperRef = ref<HTMLDivElement | null>(null);
77
+ const carouselContainerRef = ref<HTMLDivElement | null>(null);
78
+ const carouselItemsRef = useTemplateRef<HTMLDivElement[]>('carouselItems');
79
+ const controlsContainerRef = ref<HTMLDivElement | null>(null);
66
80
  const carouselInitComplete = ref(false);
81
+ const userHasInteracted = ref(false);
67
82
 
68
- const currentIndex = ref(1);
69
- const itemCount = ref(props.data.items.length);
70
- const offset = ref(1);
71
- const previousOffset = ref(1);
72
- const transitionSpeedStr = props.transitionSpeed + 'ms';
73
- const transitionRunning = ref(false);
74
-
75
- const actionPrevious = () => {
76
- // if (transitionRunning.value) return;
83
+ const initialItemOffset = computed(() => {
84
+ return props.useFlipAnimation ? 1 : 2;
85
+ });
86
+ const circularOffsetBase = computed(() => {
87
+ return props.useFlipAnimation ? 1 : Math.floor(2 * initialItemOffset.value);
88
+ });
77
89
 
78
- offset.value = -1;
79
- onTransitionEnd();
90
+ function getOffsetIndex(index: number, offset: number, itemCount: number): number {
91
+ return (index + offset) % itemCount;
80
92
  }
81
93
 
82
- const actionNext = () => {
83
- // if (transitionRunning.value) return;
94
+ const currentIndex = ref(0);
95
+ const itemCount = ref(props.carouselDataIds.length);
96
+ const transitionSpeedStr = props.transitionSpeed + 'ms';
84
97
 
85
- offset.value = 1;
86
- onTransitionEnd();
87
- }
98
+ const itemWidth = ref(0);
99
+ const itemWidthOffsetStr = computed(() => {
100
+ if (props.allowCarouselOverflow) {
101
+ if (props.useFlipAnimation) {
102
+ return `calc(-${initialItemOffset.value} * ${itemWidth.value}px - var(--_carousel-item-track-gap))`; // Good
103
+ } else {
104
+ return `calc(-${initialItemOffset.value} * ${itemWidth.value}px - (2 * var(--_carousel-item-track-gap)))`; // Good
105
+ }
106
+ } else {
107
+ if (props.useFlipAnimation) {
108
+ return `calc(-${initialItemOffset.value} * ${itemWidth.value}px - var(--_carousel-item-track-gap))`; // Goof
109
+ } else {
110
+ return `calc(-${initialItemOffset.value} * ${itemWidth.value}px - (2 * var(--_carousel-item-track-gap)))`; // Good
111
+ }
112
+ }
113
+ });
114
+ const currentActiveIndex = ref(0);
88
115
 
89
- const updateOrder = (index: number, order: number) => {
90
- if (carouselItems.value !== null && thumbnailItems.value !== null) {
91
- carouselItems.value[index - 1].style.order = order.toString();
92
- thumbnailItems.value[index - 1].style.order = order.toString();
116
+ const updateItemOrder = (index: number, order: number, zIndex: number = 2) => {
117
+ if (carouselItemsRef?.value && carouselItemsRef.value[index]) {
118
+ carouselItemsRef.value[index].style.order = order.toString();
119
+ carouselItemsRef.value[index].style.zIndex = zIndex.toString();
93
120
  }
94
121
  };
95
122
 
96
- const initialSetup = () => {
97
- const items = carouselItems.value;
98
- const thumbs = thumbnailItems.value;
123
+ function analyzeOffsets(offsets: number[]) {
124
+ const counts = new Map<number, number>();
99
125
 
100
- items?.forEach((item, index) => {
101
- item.style.zIndex = index === 0 || index === itemCount.value - 1 ? '1' : '2';
102
- item.style.order = String(index + 1);
103
- // item.setAttribute('data-order', String(index + 1));
126
+ offsets.forEach((val) => {
127
+ counts.set(val, (counts.get(val) || 0) + 1);
104
128
  });
105
- thumbs?.forEach((thumb, index) => {
106
- thumb.style.zIndex = index === 0 || index === itemCount.value - 1 ? '1' : '2';
107
- thumb.style.order = String(index + 1);
108
- // thumb.setAttribute('data-order', String(index + 1));
109
- });
110
- carouselInitComplete.value = true;
111
- }
112
129
 
130
+ const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
113
131
 
114
- const onTransitionEnd = () => {
132
+ const majorityValue = sorted[0][0];
133
+ const minorityValue = sorted[sorted.length - 1][0];
134
+ const minorityIndex = offsets.findIndex((val) => val === minorityValue);
115
135
 
116
- // transitionRunning.value = true;
117
- const items = carouselItems.value;
118
- const thumbs = thumbnailItems.value;
136
+ return {
137
+ majorityValue,
138
+ minorityValue,
139
+ minorityIndex,
140
+ };
141
+ }
119
142
 
120
- if (!items || !Array.isArray(items)) return;
121
- if (!thumbs || !Array.isArray(thumbs)) return;
143
+ const reorderItems = (direction: 'next' | 'previous' | 'jump' = 'jump', skipAnimation: boolean = false) => {
144
+ if (!carouselItemsRef?.value) return;
122
145
 
123
- // 1. Capture initial positions for both main items and thumbnails
124
- const firstRects = items.map(el => el.getBoundingClientRect());
125
- const firstThumbRects = thumbs.map(el => el.getBoundingClientRect());
146
+ // Capture positions before reordering (only if we're going to animate)
147
+ const beforeRects = skipAnimation ? [] : carouselItemsRef.value.map((item) => item.getBoundingClientRect());
126
148
 
127
- // 2. Update orders
128
- let firstVisualElementIndex = currentIndex.value; // Track which element should be visually first
149
+ // Apply new order and z-index based on direction
150
+ let order = 1;
129
151
 
130
- if (carouselInitComplete.value) {
131
- if (offset.value === 1) {
132
- const localOffset = offset.value === previousOffset.value ? offset.value : 2; // Ensure we have a valid offset
133
- currentIndex.value = currentIndex.value === itemCount.value ? 1 : currentIndex.value + localOffset;
134
- firstVisualElementIndex = currentIndex.value;
135
- let order = 1;
152
+ // For items from currentActiveIndex to end
153
+ for (let i = currentActiveIndex.value; i < itemCount.value; i++) {
154
+ let zIndex = 2; // default normal z-index
136
155
 
137
- for (let i = currentIndex.value; i <= itemCount.value; i++) updateOrder(i, order++);
138
- for (let i = 1; i < currentIndex.value; i++) updateOrder(i, order++);
156
+ if (i === currentActiveIndex.value) {
157
+ // The item becoming visible
158
+ if (direction === 'previous') {
159
+ // When going previous, the item moving to first position should go behind
160
+ zIndex = 1;
161
+ } else {
162
+ // Normal case - visible item gets highest z-index
163
+ zIndex = 3;
164
+ }
165
+ }
139
166
 
140
- } else {
141
- const localOffset = offset.value === previousOffset.value ? offset.value : -2; // Ensure we have a valid offset
142
- currentIndex.value = currentIndex.value === 1 ? itemCount.value : currentIndex.value + localOffset;
143
- firstVisualElementIndex = currentIndex.value;
144
- let order = itemCount.value;
167
+ updateItemOrder(i, order++, zIndex);
168
+ }
145
169
 
146
- for (let i = currentIndex.value; i >= 1; i--) updateOrder(i, order--);
147
- for (let i = itemCount.value; i > currentIndex.value; i--) updateOrder(i, order--);
148
- }
149
- previousOffset.value = offset.value; // Store the previous offset for next transition
170
+ // For items from 0 to currentActiveIndex
171
+ for (let i = 0; i < currentActiveIndex.value; i++) {
172
+ // Items that wrap around get lower z-index to slide behind
173
+ const zIndex = 1;
174
+ updateItemOrder(i, order++, zIndex);
175
+ }
150
176
 
151
- // setTimeout(() => {
152
- // transitionRunning.value = false;
153
- // }, props.transitionSpeed);
177
+ // Skip animation if requested (for initial setup)
178
+ if (skipAnimation) {
179
+ return;
154
180
  }
155
181
 
156
- // 3. Next tick: capture new positions & animate both main items and thumbnails
182
+ // Animate using FLIP technique
157
183
  requestAnimationFrame(() => {
158
- const lastRects = items.map(el => el.getBoundingClientRect());
159
- const lastThumbRects = thumbs.map(el => el.getBoundingClientRect());
160
-
161
- // Animate main carousel items
162
- items.forEach((el, i) => {
163
- const dx = firstRects[i].left - lastRects[i].left;
164
- const dy = firstRects[i].top - lastRects[i].top;
165
-
166
- el.style.transition = 'none';
167
- el.style.transform = `translate(${dx}px, ${dy}px)`;
168
-
169
- requestAnimationFrame(() => {
170
- el.style.transition = `transform ${transitionSpeedStr} ease`;
171
- el.style.transform = '';
172
-
173
- // Set z-index after the transition actually completes
174
- const elementIndex = i + 1; // Convert to 1-based index to match your logic
175
- const isFirstVisual = elementIndex === firstVisualElementIndex;
176
-
177
- // Listen for transition end to update z-index
178
- const handleTransitionEnd = (event: TransitionEvent) => {
179
- if (event.propertyName === 'transform') {
180
- el.style.zIndex = isFirstVisual ? '1' : '2';
181
- el.removeEventListener('transitionend', handleTransitionEnd);
184
+ const afterRects = carouselItemsRef.value!.map((item) => item.getBoundingClientRect());
185
+
186
+ // Calculate offset values
187
+ const offsetValues = beforeRects.map((beforeRect, index) => {
188
+ const afterRect = afterRects[index];
189
+ return beforeRect.left - afterRect.left;
190
+ });
191
+
192
+ const leftValues = analyzeOffsets(offsetValues);
193
+
194
+ carouselItemsRef.value!.forEach((item, index) => {
195
+ const deltaX = beforeRects[index].left - afterRects[index].left;
196
+
197
+ if (deltaX !== 0) {
198
+ item.style.transition = 'none';
199
+ item.style.transform = `translateX(${deltaX}px)`;
200
+
201
+ requestAnimationFrame(() => {
202
+ const shouldTransition = carouselInitComplete.value && userHasInteracted.value;
203
+ let transitionProperties = 'none';
204
+
205
+ if (shouldTransition) {
206
+ if (props.allowCarouselOverflow) {
207
+ if (props.useFlipAnimation) {
208
+ transitionProperties = `transform ${transitionSpeedStr} ease`;
209
+ } else {
210
+ if (leftValues.minorityIndex !== index) {
211
+ transitionProperties = `transform ${transitionSpeedStr} ease`;
212
+ }
213
+ }
214
+ } else {
215
+ if (props.useFlipAnimation) {
216
+ transitionProperties = `transform ${transitionSpeedStr} ease`;
217
+ } else {
218
+ if (leftValues.minorityIndex !== index) {
219
+ transitionProperties = `transform ${transitionSpeedStr} ease`;
220
+ }
221
+ }
222
+ }
182
223
  }
183
- };
184
224
 
185
- el.addEventListener('transitionend', handleTransitionEnd);
186
- });
225
+ item.style.transition = transitionProperties;
226
+ item.style.transform = 'translateX(0)';
227
+
228
+ // After animation completes, normalize z-index values
229
+ const handleTransitionEnd = (event: TransitionEvent) => {
230
+ if (event.propertyName === 'transform') {
231
+ // Set final z-index: current item gets highest, others get normal
232
+ const isCurrentlyVisible = index === currentActiveIndex.value;
233
+ item.style.zIndex = isCurrentlyVisible ? '3' : '2';
234
+ item.removeEventListener('transitionend', handleTransitionEnd);
235
+ }
236
+ };
237
+
238
+ if (shouldTransition) {
239
+ item.addEventListener('transitionend', handleTransitionEnd);
240
+ } else {
241
+ // If no transition, immediately normalize z-index
242
+ const isCurrentlyVisible = index === currentActiveIndex.value;
243
+ item.style.zIndex = isCurrentlyVisible ? '3' : '2';
244
+ }
245
+ });
246
+ }
187
247
  });
248
+ });
249
+ };
250
+
251
+ const actionPrevious = () => {
252
+ if (!carouselInitComplete.value || !carouselItemsRef?.value) return;
253
+
254
+ userHasInteracted.value = true;
188
255
 
189
- // Animate thumbnail items
190
- thumbs.forEach((thumb, i) => {
191
- const dx = firstThumbRects[i].left - lastThumbRects[i].left;
192
- const dy = firstThumbRects[i].top - lastThumbRects[i].top;
256
+ if (currentActiveIndex.value === 0) {
257
+ currentActiveIndex.value = itemCount.value - 1;
258
+ } else {
259
+ currentActiveIndex.value = currentActiveIndex.value === 0 ? itemCount.value - 1 : currentActiveIndex.value - 1;
260
+ }
193
261
 
194
- thumb.style.transition = 'none';
195
- thumb.style.transform = `translate(${dx}px, ${dy}px)`;
262
+ reorderItems('previous');
263
+ currentIndex.value = currentActiveIndex.value;
264
+ };
196
265
 
197
- requestAnimationFrame(() => {
198
- thumb.style.transition = `transform ${transitionSpeedStr} ease`;
199
- thumb.style.transform = '';
266
+ const actionNext = () => {
267
+ if (!carouselInitComplete.value || !carouselItemsRef?.value) return;
200
268
 
201
- // Set z-index after the transition actually completes
202
- const thumbIndex = i + 1; // Convert to 1-based index
203
- const isActiveThumbnail = thumbIndex === firstVisualElementIndex;
269
+ userHasInteracted.value = true;
204
270
 
205
- // Listen for transition end to update z-index
206
- const handleThumbTransitionEnd = (event: TransitionEvent) => {
207
- if (event.propertyName === 'transform') {
208
- thumb.style.zIndex = isActiveThumbnail ? '1' : '2';
209
- thumb.removeEventListener('transitionend', handleThumbTransitionEnd);
210
- }
211
- };
271
+ if (currentActiveIndex.value === itemCount.value - 1) {
272
+ currentActiveIndex.value = 0;
273
+ } else {
274
+ currentActiveIndex.value = currentActiveIndex.value === itemCount.value - 1 ? 0 : currentActiveIndex.value + 1;
275
+ }
276
+
277
+ reorderItems('next');
278
+ currentIndex.value = currentActiveIndex.value;
279
+ };
280
+
281
+ const jumpToFrame = (index: number) => {
282
+ if (index >= 0 && index < itemCount.value) {
283
+ // Only mark as user interaction if carousel is already initialized
284
+ if (carouselInitComplete.value) {
285
+ userHasInteracted.value = true;
286
+ }
287
+
288
+ currentActiveIndex.value = getOffsetIndex(index, circularOffsetBase.value, itemCount.value);
289
+
290
+ // currentActiveIndex.value = index;
291
+ reorderItems('jump');
292
+ currentIndex.value = currentActiveIndex.value;
293
+ }
294
+ };
295
+
296
+ const checkAndMoveLastItem = () => {
297
+ if (props.allowCarouselOverflow || !props.useFlipAnimation) {
298
+ currentActiveIndex.value = itemCount.value - initialItemOffset.value;
299
+ reorderItems('jump', true); // Skip animation during initial setup
300
+ currentIndex.value = currentActiveIndex.value;
301
+ }
302
+ };
212
303
 
213
- thumb.addEventListener('transitionend', handleThumbTransitionEnd);
214
- });
304
+ const initialSetup = () => {
305
+ if (carouselItemsRef?.value && carouselItemsRef.value.length > 0 && carouselItemsRef.value[0]) {
306
+ itemWidth.value = carouselItemsRef.value[0].offsetWidth;
307
+
308
+ // Set initial order and z-index for all items
309
+ carouselItemsRef.value.forEach((item, index) => {
310
+ item.style.order = String(index + 1);
311
+ item.dataset.order = String(index + 1);
312
+ // First item gets higher z-index, others get normal z-index
313
+ item.style.zIndex = index === 0 ? '3' : '2';
215
314
  });
216
- });
315
+ }
217
316
 
218
317
  carouselInitComplete.value = true;
318
+ checkAndMoveLastItem();
219
319
  };
220
320
 
221
- onMounted(() => {
321
+ const { direction } = useSwipe(carouselContainerRef, {
322
+ passive: false,
323
+ onSwipeEnd() {
324
+ if (direction.value === 'left') {
325
+ actionNext();
326
+ } else if (direction.value === 'right') {
327
+ actionPrevious();
328
+ }
329
+ },
330
+ });
331
+
332
+ useEventListener(carouselContainerRef, 'keydown', (event: KeyboardEvent) => {
333
+ if (event.key === 'ArrowLeft') {
334
+ actionPrevious();
335
+ } else if (event.key === 'ArrowRight') {
336
+ actionNext();
337
+ }
338
+ });
339
+
340
+ useEventListener(controlsContainerRef, 'keydown', (event: KeyboardEvent) => {
341
+ if (event.key === 'ArrowLeft') {
342
+ actionPrevious();
343
+ } else if (event.key === 'ArrowRight') {
344
+ actionNext();
345
+ }
346
+ });
347
+
348
+ useResizeObserver(carouselWrapperRef, async () => {
222
349
  initialSetup();
223
350
  });
224
351
 
352
+ onMounted(() => {
353
+ initialSetup();
354
+ });
225
355
  </script>
226
356
 
227
357
  <style lang="css">
228
358
  .carousel-flip {
359
+ --_carousel-item-track-gap: 10px;
229
360
 
230
361
  display: grid;
231
362
  grid-template-columns: 1fr;
232
363
  gap: 10px;
233
364
 
234
- &.controls-inside {
235
- grid-template-areas: "carousel-content";
236
- isolation: isolate;
237
-
238
- .item-container {
239
- grid-area: carousel-content;
240
- z-index: 1;
241
- }
242
-
243
- .controls-container {
244
- grid-area: carousel-content;
245
- z-index: 2;
246
- height: fit-content;
247
- align-self: flex-end;
248
- }
365
+ .sr-only {
366
+ position: absolute;
367
+ width: 1px;
368
+ height: 1px;
369
+ padding: 0;
370
+ margin: -1px;
371
+ overflow: hidden;
372
+ clip: rect(0, 0, 0, 0);
373
+ white-space: nowrap;
374
+ border: 0;
249
375
  }
250
376
 
251
377
  .item-container {
252
378
  display: flex;
253
- gap: 10px;
254
- overflow-x: auto;
255
- padding-block: 10px;
256
- padding-inline: 10px;
257
- outline: 1px solid light-dark(#00000090, #f00ff090);
379
+ gap: var(--_carousel-item-track-gap);
380
+ overflow-x: hidden;
381
+ position: relative;
258
382
 
259
- /* scroll-snap-type: x mandatory; */
383
+ max-inline-size: var(--_carousel-display-max-width);
384
+ margin-inline: auto;
260
385
 
261
- /* isolation: isolate; */
262
- position: relative;
386
+ &.allow-overflow {
387
+ overflow-x: initial;
388
+ }
263
389
 
264
390
  .item {
265
391
  display: flex;
266
- flex-direction: column;
267
- align-items: center;
268
- justify-content: center;
269
-
270
- /* transition: transform v-bind(transitionSpeedStr) ease; */
271
- /* For FLIP smoothness */
392
+ flex: 0 0 100%;
393
+ position: relative;
272
394
 
273
- aspect-ratio: 4 / 3;
395
+ margin-inline: auto;
274
396
 
275
- min-inline-size: 600px;
276
- color: light-dar(#aaa, #333);
277
- padding-block: 10px;
278
- padding-inline: 10px;
279
- border-radius: 4px;
280
- outline: 1px solid light-dark(#00000090, #f00ff090);
397
+ max-inline-size: calc(var(--_carousel-container-width) + var(--_carousel-item-track-gap) - (2 * var(--_carousel-item-edge-preview-width)));
281
398
 
282
- background-color: light-dark(#f00, #00f);
399
+ translate: calc(v-bind(itemWidthOffsetStr) - var(--_carousel-item-track-gap) + var(--_carousel-item-edge-preview-width)) 0;
283
400
 
284
- /* scroll-snap-align: none center; */
285
-
286
- &:nth-child(odd) {
287
- background-color: light-dark(#00f, #f00);
401
+ &.loaded {
402
+ transition: transform v-bind(transitionSpeedStr) ease;
288
403
  }
289
404
  }
290
405
  }
291
406
 
292
-
293
407
  .controls-container {
294
-
295
408
  display: flex;
296
- gap: 20px;
297
-
409
+ align-items: center;
410
+ justify-content: flex-end;
411
+ max-inline-size: var(--_carousel-display-max-width);
412
+ margin-inline: auto;
298
413
 
414
+ .markers-container {
415
+ .markers-list {
416
+ display: flex;
417
+ flex-direction: row;
418
+ gap: 10px;
419
+ list-style-type: none;
420
+ margin: unset;
421
+ padding: unset;
422
+
423
+ .markers-item {
424
+ .btn-marker {
425
+ border: 1px solid transparent;
426
+ outline: 1px solid transparent;
427
+ box-shadow: none;
428
+ cursor: pointer;
429
+ transition: background-color v-bind(transitionSpeedStr) linear;
430
+
431
+ &.active {
432
+ background-color: light-dark(var(--gray-12), var(--gray-00));
433
+ }
434
+ }
435
+ }
436
+ }
437
+ }
299
438
 
300
439
  .buttons-container {
301
440
  display: flex;
302
- flex-grow: 1;
303
441
  align-items: center;
304
442
  justify-content: end;
305
443
  gap: 20px;
306
444
 
307
-
308
445
  .btn-action {
309
- padding: 10px 20px;
310
- border-radius: 4px;
311
- background-color: light-dark(#000, #fff);
312
- color: light-dark(#fff, #000);
313
- border: none;
314
- cursor: pointer;
315
- height: fit-content;
316
-
317
- transition: background-color 0.3s ease, color 0.3s ease;
318
-
319
- &:hover {
320
- background-color: light-dark(#0009, #fff9);
321
- }
322
-
323
- &:active {
324
- background-color: light-dark(#0009, #fff9);
325
- }
326
-
327
- &:disabled {
328
- background-color: light-dark(#0003, #fff3);
329
- cursor: not-allowed;
330
- }
331
- }
332
- }
333
-
334
- .thumbnail-container {
335
- padding-block: 10px;
336
- padding-inline: 10px;
337
- outline: 1px solid light-dark(#00000090, #f00ff090);
338
- max-inline-size: 40%;
339
-
340
- .thumbnail-list {
341
446
  display: flex;
342
- gap: 10px;
343
- list-style-type: none;
344
- padding-block: 8px;
345
- padding-inline: 8px;
346
- margin-block: 0;
347
- margin-inline: 0;
348
-
349
- outline: 1px solid light-dark(#00000090, #f00ff090);
350
- overflow-x: auto;
351
-
352
- .thumbnail-item {
353
-
354
- display: flex;
355
- align-items: center;
356
- justify-content: center;
447
+ align-items: center;
448
+ justify-content: center;
357
449
 
358
- aspect-ratio: 3 / 4;
359
- min-inline-size: 120px;
360
- outline: 1px solid light-dark(#f00, #00f);
361
- border-radius: 4px;
362
-
363
- background-color: light-dark(#f00, #00f);
364
-
365
- &:nth-child(odd) {
366
- background-color: light-dark(#00f, #f00);
367
- }
368
-
369
-
370
- .thumbnail-item_inner {}
450
+ cursor: pointer;
451
+ height: fit-content;
371
452
 
453
+ .arrows-icon {
454
+ width: 24px;
455
+ height: 24px;
372
456
  }
373
457
  }
374
458
  }
375
459
  }
376
-
377
460
  }
378
461
  </style>
@@ -0,0 +1,325 @@
1
+ <template>
2
+ <section class="carousel-infinite" :class="[elementClasses]" ref="carouselWrapperRef" role="region" aria-label="Image carousel">
3
+ <!-- Screen reader announcement for current item -->
4
+ <div aria-live="polite" aria-atomic="true" class="sr-only">Item {{ currentVisibleIndex + 1 }} of {{ itemCount }}</div>
5
+
6
+ <LayoutRow tag="div" variant="full-width" :style-class-passthrough="['mbe-20']">
7
+ <div tabindex="0" class="item-container" :class="{ 'allow-overflow': allowCarouselOverflow }" ref="carouselContainerRef" role="group" aria-label="Carousel items">
8
+ <div v-for="(item, index) in carouselDataIds" :key="index" class="item" ref="carouselItems" :aria-current="currentVisibleIndex === index ? 'true' : 'false'">
9
+ <slot :name="item"></slot>
10
+ </div>
11
+ </div>
12
+ </LayoutRow>
13
+
14
+ <LayoutRow tag="div" variant="full-width" :style-class-passthrough="['mbe-20']">
15
+ <div tabindex="0" class="controls-container" ref="controlsContainerRef">
16
+ <div class="markers-container">
17
+ <ul class="markers-list">
18
+ <li v-for="index in itemCount" :key="index" class="markers-item">
19
+ <button
20
+ @click.prevent="jumpToFrame(index - 1)"
21
+ class="btn-marker"
22
+ :class="[{ active: currentVisibleIndex === index - 1 }]"
23
+ :aria-label="`Jump to item ${Math.floor(index + 1)}`"
24
+ ></button>
25
+ </li>
26
+ </ul>
27
+ </div>
28
+ <div class="buttons-container">
29
+ <button type="button" @click.prevent="actionPrevious()" class="btn-action" aria-label="Go to previous item">
30
+ <Icon name="ic:outline-keyboard-arrow-left" class="arrows-icon" />
31
+ </button>
32
+ <button type="button" @click.prevent="actionNext()" class="btn-action" aria-label="Go to next item">
33
+ <Icon name="ic:outline-keyboard-arrow-right" class="arrows-icon" />
34
+ </button>
35
+ </div>
36
+ </div>
37
+ </LayoutRow>
38
+ </section>
39
+ </template>
40
+
41
+ <script setup lang="ts">
42
+ import { useEventListener, useResizeObserver, useSwipe } from '@vueuse/core';
43
+
44
+ const props = defineProps({
45
+ carouselDataIds: {
46
+ type: Array as PropType<string[]>,
47
+ default: () => [],
48
+ },
49
+ styleClassPassthrough: {
50
+ type: Array as PropType<string[]>,
51
+ default: () => [],
52
+ },
53
+ transitionSpeed: {
54
+ type: Number,
55
+ default: 200,
56
+ },
57
+ allowCarouselOverflow: {
58
+ type: Boolean,
59
+ default: false,
60
+ },
61
+ returnToStart: {
62
+ type: Boolean,
63
+ default: false,
64
+ },
65
+ });
66
+
67
+ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
68
+
69
+ const carouselWrapperRef = ref<HTMLDivElement | null>(null);
70
+ const carouselContainerRef = ref<HTMLDivElement | null>(null);
71
+ const carouselItemsRef = useTemplateRef<HTMLDivElement[]>('carouselItems');
72
+ const controlsContainerRef = ref<HTMLDivElement | null>(null);
73
+ const carouselInitComplete = ref(false);
74
+
75
+ const currentIndex = ref(0);
76
+ const itemCount = ref(props.carouselDataIds.length);
77
+ const transitionSpeedStr = props.transitionSpeed + 'ms';
78
+
79
+ const itemWidth = ref(0);
80
+ const itemWidthOffsetStr = computed(() => {
81
+ return `-${itemWidth.value}px`;
82
+ });
83
+
84
+ const currentVisibleIndex = ref(0);
85
+
86
+ const carouselContainerRefLeftPosition = computed(() => {
87
+ return carouselContainerRef.value ? carouselContainerRef.value.getBoundingClientRect().left : 0;
88
+ });
89
+ const fullScreenOffsset = computed(() => {
90
+ return `-${Math.floor(carouselContainerRefLeftPosition.value)}px`;
91
+ });
92
+ console.log('INIT: carouselContainerRefLeftPosition:', carouselContainerRefLeftPosition.value, 'fullScreenOffsset:', fullScreenOffsset.value);
93
+
94
+ const updateItemOrder = (index: number, order: number, zIndex: number = 2) => {
95
+ if (carouselItemsRef?.value && carouselItemsRef.value[index]) {
96
+ carouselItemsRef.value[index].style.order = order.toString();
97
+ carouselItemsRef.value[index].style.zIndex = zIndex.toString();
98
+ }
99
+ };
100
+
101
+ const reorderItems = (direction: 'next' | 'previous' | 'jump' = 'jump') => {
102
+ console.log(`Reordering items in direction: ${direction}`);
103
+ if (!carouselItemsRef?.value || !carouselInitComplete.value) return;
104
+
105
+ // Capture positions before reordering
106
+ const beforeRects = carouselItemsRef.value.map((item) => item.getBoundingClientRect());
107
+
108
+ // Apply new order and z-index based on direction
109
+ let order = 1;
110
+
111
+ // For items from currentVisibleIndex to end
112
+ for (let i = currentVisibleIndex.value; i < itemCount.value; i++) {
113
+ let zIndex = 2; // default normal z-index
114
+
115
+ if (i === currentVisibleIndex.value) {
116
+ // The item becoming visible
117
+ if (direction === 'previous') {
118
+ // When going previous, the item moving to first position should go behind
119
+ zIndex = 1;
120
+ } else {
121
+ // Normal case - visible item gets highest z-index
122
+ zIndex = 3;
123
+ }
124
+ }
125
+
126
+ updateItemOrder(i, order++, zIndex);
127
+ }
128
+
129
+ // For items from 0 to currentVisibleIndex
130
+ for (let i = 0; i < currentVisibleIndex.value; i++) {
131
+ // Items that wrap around get lower z-index to slide behind
132
+ const zIndex = 1;
133
+ updateItemOrder(i, order++, zIndex);
134
+ }
135
+ };
136
+
137
+ const actionPrevious = () => {
138
+ if (!carouselInitComplete.value || !carouselItemsRef?.value) return;
139
+
140
+ if (props.returnToStart && currentVisibleIndex.value === 0) {
141
+ currentVisibleIndex.value = itemCount.value - 1;
142
+ } else {
143
+ currentVisibleIndex.value = currentVisibleIndex.value === 0 ? itemCount.value - 1 : currentVisibleIndex.value - 1;
144
+ }
145
+
146
+ reorderItems('previous');
147
+ currentIndex.value = currentVisibleIndex.value;
148
+ };
149
+
150
+ const actionNext = () => {
151
+ if (!carouselInitComplete.value || !carouselItemsRef?.value) return;
152
+
153
+ if (props.returnToStart && currentVisibleIndex.value === itemCount.value - 1) {
154
+ currentVisibleIndex.value = 0;
155
+ } else {
156
+ currentVisibleIndex.value = currentVisibleIndex.value === itemCount.value - 1 ? 0 : currentVisibleIndex.value + 1;
157
+ }
158
+
159
+ reorderItems('next');
160
+ currentIndex.value = currentVisibleIndex.value;
161
+ };
162
+
163
+ const jumpToFrame = (index: number) => {
164
+ if (index >= 0 && index < itemCount.value) {
165
+ currentVisibleIndex.value = index;
166
+ reorderItems('jump');
167
+ currentIndex.value = currentVisibleIndex.value;
168
+ }
169
+ };
170
+
171
+ const checkAndMoveLastItem = () => {
172
+ if (props.allowCarouselOverflow) {
173
+ const itemsFit = Math.floor(carouselContainerRefLeftPosition.value / itemWidth.value + 1);
174
+ jumpToFrame(itemCount.value - 1);
175
+ }
176
+ };
177
+
178
+ const initialSetup = () => {
179
+ if (carouselItemsRef?.value && carouselItemsRef.value.length > 0 && carouselItemsRef.value[0]) {
180
+ itemWidth.value = carouselItemsRef.value[0].offsetWidth;
181
+
182
+ // Set initial order and z-index for all items
183
+ carouselItemsRef.value.forEach((item, index) => {
184
+ item.style.order = String(index + 1);
185
+ // First item gets higher z-index, others get normal z-index
186
+ item.style.zIndex = index === 0 ? '3' : '2';
187
+ });
188
+ }
189
+
190
+ carouselInitComplete.value = true;
191
+ checkAndMoveLastItem();
192
+ };
193
+
194
+ const { direction } = useSwipe(carouselContainerRef, {
195
+ passive: false,
196
+ onSwipeEnd() {
197
+ if (direction.value === 'left') {
198
+ actionNext();
199
+ } else if (direction.value === 'right') {
200
+ actionPrevious();
201
+ }
202
+ },
203
+ });
204
+
205
+ useEventListener(carouselContainerRef, 'keydown', (event: KeyboardEvent) => {
206
+ if (event.key === 'ArrowLeft') {
207
+ actionPrevious();
208
+ } else if (event.key === 'ArrowRight') {
209
+ actionNext();
210
+ }
211
+ });
212
+
213
+ useEventListener(controlsContainerRef, 'keydown', (event: KeyboardEvent) => {
214
+ if (event.key === 'ArrowLeft') {
215
+ actionPrevious();
216
+ } else if (event.key === 'ArrowRight') {
217
+ actionNext();
218
+ }
219
+ });
220
+
221
+ useResizeObserver(carouselWrapperRef, async () => {
222
+ initialSetup();
223
+ });
224
+
225
+ onMounted(() => {
226
+ initialSetup();
227
+ console.log('onMounted: carouselContainerRefLeftPosition:', carouselContainerRefLeftPosition.value, 'fullScreenOffsset:', fullScreenOffsset.value);
228
+ });
229
+ </script>
230
+
231
+ <style lang="css">
232
+ .carousel-infinite {
233
+ --_carousel-item-track-gap: 10px;
234
+
235
+ display: grid;
236
+ grid-template-columns: 1fr;
237
+ gap: 10px;
238
+
239
+ .sr-only {
240
+ position: absolute;
241
+ width: 1px;
242
+ height: 1px;
243
+ padding: 0;
244
+ margin: -1px;
245
+ overflow: hidden;
246
+ clip: rect(0, 0, 0, 0);
247
+ white-space: nowrap;
248
+ border: 0;
249
+ }
250
+
251
+ .item-container {
252
+ display: flex;
253
+ gap: var(--_carousel-item-track-gap);
254
+ overflow-x: hidden;
255
+ position: relative;
256
+
257
+ &.allow-overflow {
258
+ overflow-x: initial;
259
+
260
+ .item {
261
+ translate: calc(v-bind(itemWidthOffsetStr) - var(--_carousel-item-track-gap)) 0;
262
+ }
263
+ }
264
+
265
+ .item {
266
+ display: flex;
267
+ flex: 0 0 100%;
268
+ max-inline-size: 800px;
269
+ position: relative;
270
+ }
271
+ }
272
+
273
+ .controls-container {
274
+ display: flex;
275
+ align-items: center;
276
+ justify-content: flex-end;
277
+
278
+ .markers-container {
279
+ .markers-list {
280
+ display: flex;
281
+ flex-direction: row;
282
+ gap: 10px;
283
+ list-style-type: none;
284
+ margin: unset;
285
+ padding: unset;
286
+
287
+ .markers-item {
288
+ .btn-marker {
289
+ border: none;
290
+ outline: none;
291
+ box-shadow: none;
292
+ cursor: pointer;
293
+ transition: background-color v-bind(transitionSpeedStr) linear;
294
+
295
+ &.active {
296
+ background-color: red;
297
+ }
298
+ }
299
+ }
300
+ }
301
+ }
302
+
303
+ .buttons-container {
304
+ display: flex;
305
+ align-items: center;
306
+ justify-content: end;
307
+ gap: 20px;
308
+
309
+ .btn-action {
310
+ display: flex;
311
+ align-items: center;
312
+ justify-content: center;
313
+
314
+ cursor: pointer;
315
+ height: fit-content;
316
+
317
+ .arrows-icon {
318
+ width: 24px;
319
+ height: 24px;
320
+ }
321
+ }
322
+ }
323
+ }
324
+ }
325
+ </style>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "4.0.2",
4
+ "version": "4.0.4",
5
5
  "main": "nuxt.config.ts",
6
6
  "scripts": {
7
7
  "clean": "rm -rf .nuxt && rm -rf .output && rm -rf .playground/.nuxt && rm -rf .playground/.output",