@fy-/fws-vue 2.2.46 → 2.2.48

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.
@@ -4,6 +4,7 @@ import type { APIPaging } from '../../composables/rest'
4
4
  import {
5
5
  ArrowLeftCircleIcon,
6
6
  ArrowRightCircleIcon,
7
+ ArrowsPointingOutIcon, // For fullscreen button
7
8
  ChevronDoubleLeftIcon,
8
9
  ChevronDoubleRightIcon,
9
10
  XCircleIcon,
@@ -15,6 +16,20 @@ import DefaultPaging from './DefaultPaging.vue'
15
16
  const isGalleryOpen = ref<boolean>(false)
16
17
  const eventBus = useEventBus()
17
18
  const sidePanel = ref<boolean>(true)
19
+ const isImageLoading = ref<boolean>(false)
20
+ const galleryRef = ref<HTMLElement | null>(null)
21
+ const isFullscreen = ref<boolean>(false)
22
+
23
+ // For mobile image manipulation
24
+ const scale = ref<number>(1)
25
+ const startDistance = ref<number>(0)
26
+ const isPinching = ref<boolean>(false)
27
+ const panPosition = reactive({ x: 0, y: 0 })
28
+ const startPanPosition = reactive({ x: 0, y: 0 })
29
+ const isPanning = ref<boolean>(false)
30
+ const lastTapTime = ref<number>(0)
31
+ const touchCenter = reactive({ x: 0.5, y: 0.5 }) // Default to center
32
+ const showMobileThumbnails = ref<boolean>(false)
18
33
 
19
34
  const props = withDefaults(
20
35
  defineProps<{
@@ -64,6 +79,47 @@ const modelValue = computed({
64
79
  })
65
80
 
66
81
  const direction = ref<'next' | 'prev'>('next')
82
+ const imageCounter = computed(() => `${modelValue.value + 1} / ${props.images.length}`)
83
+ let focusableElements: HTMLElement[] = []
84
+
85
+ // Trap focus within gallery for accessibility
86
+ function getFocusableElements(element: HTMLElement): HTMLElement[] {
87
+ return Array.from(
88
+ element.querySelectorAll(
89
+ 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])',
90
+ ),
91
+ ).filter(
92
+ el => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'),
93
+ ) as HTMLElement[]
94
+ }
95
+
96
+ function handleTabKey(event: KeyboardEvent) {
97
+ if (!isGalleryOpen.value || event.key !== 'Tab' || focusableElements.length <= 1) return
98
+
99
+ const firstElement = focusableElements[0]
100
+ const lastElement = focusableElements[focusableElements.length - 1]
101
+
102
+ // Shift + Tab on first element should focus the last element
103
+ if (event.shiftKey && document.activeElement === firstElement) {
104
+ event.preventDefault()
105
+ lastElement.focus()
106
+ }
107
+ // Tab on last element should focus the first element
108
+ else if (!event.shiftKey && document.activeElement === lastElement) {
109
+ event.preventDefault()
110
+ firstElement.focus()
111
+ }
112
+ }
113
+
114
+ // Toggle fullscreen mode
115
+ function toggleFullscreen() {
116
+ isFullscreen.value = !isFullscreen.value
117
+
118
+ // Reset zoom and pan when toggling fullscreen
119
+ scale.value = 1
120
+ panPosition.x = 0
121
+ panPosition.y = 0
122
+ }
67
123
 
68
124
  function setModal(value: boolean) {
69
125
  if (value === true) {
@@ -72,14 +128,39 @@ function setModal(value: boolean) {
72
128
  if (!import.meta.env.SSR) {
73
129
  document.addEventListener('keydown', handleKeyboardInput)
74
130
  document.addEventListener('keyup', handleKeyboardRelease)
131
+ document.addEventListener('keydown', handleTabKey)
75
132
  }
133
+
134
+ // Initialize focus trap after gallery opens
135
+ setTimeout(() => {
136
+ if (galleryRef.value) {
137
+ focusableElements = getFocusableElements(galleryRef.value)
138
+ // Focus the close button if present
139
+ const closeButton = galleryRef.value.querySelector('button[aria-label="Close gallery"]') as HTMLElement
140
+ if (closeButton) {
141
+ closeButton.focus()
142
+ }
143
+ else if (focusableElements.length > 0) {
144
+ focusableElements[0].focus()
145
+ }
146
+ }
147
+ }, 100)
76
148
  }
77
149
  else {
78
150
  if (props.onClose) props.onClose()
79
151
  document.body.style.overflow = '' // Restore scrolling
152
+
153
+ // Reset zoom, pan, fullscreen and thumbnail states when closing
154
+ scale.value = 1
155
+ panPosition.x = 0
156
+ panPosition.y = 0
157
+ isFullscreen.value = false
158
+ showMobileThumbnails.value = false
159
+
80
160
  if (!import.meta.env.SSR) {
81
161
  document.removeEventListener('keydown', handleKeyboardInput)
82
162
  document.removeEventListener('keyup', handleKeyboardRelease)
163
+ document.removeEventListener('keydown', handleTabKey)
83
164
  }
84
165
  }
85
166
  isGalleryOpen.value = value
@@ -96,7 +177,13 @@ function openGalleryImage(index: number | undefined) {
96
177
  }
97
178
 
98
179
  function goNextImage() {
180
+ // Reset zoom and pan when navigating
181
+ scale.value = 1
182
+ panPosition.x = 0
183
+ panPosition.y = 0
184
+
99
185
  direction.value = 'next'
186
+ isImageLoading.value = true
100
187
  if (modelValue.value < props.images.length - 1) {
101
188
  modelValue.value++
102
189
  }
@@ -106,7 +193,13 @@ function goNextImage() {
106
193
  }
107
194
 
108
195
  function goPrevImage() {
196
+ // Reset zoom and pan when navigating
197
+ scale.value = 1
198
+ panPosition.x = 0
199
+ panPosition.y = 0
200
+
109
201
  direction.value = 'prev'
202
+ isImageLoading.value = true
110
203
  if (modelValue.value > 0) {
111
204
  modelValue.value--
112
205
  }
@@ -124,41 +217,159 @@ const modelValueSrc = computed(() => {
124
217
 
125
218
  const start = reactive({ x: 0, y: 0 })
126
219
 
220
+ // Calculate the distance between two touch points
221
+ function getDistance(touch1: Touch, touch2: Touch): number {
222
+ const dx = touch1.clientX - touch2.clientX
223
+ const dy = touch1.clientY - touch2.clientY
224
+ return Math.sqrt(dx * dx + dy * dy)
225
+ }
226
+
227
+ // Calculate the center point between two touches
228
+ function getTouchCenter(touch1: Touch, touch2: Touch, element: HTMLElement) {
229
+ const rect = element.getBoundingClientRect()
230
+ const centerX = (touch1.clientX + touch2.clientX) / 2
231
+ const centerY = (touch1.clientY + touch2.clientY) / 2
232
+
233
+ return {
234
+ x: (centerX - rect.left) / rect.width,
235
+ y: (centerY - rect.top) / rect.height,
236
+ }
237
+ }
238
+
127
239
  function touchStart(event: TouchEvent) {
128
- const touch = event.touches[0]
129
- const targetElement = touch.target as HTMLElement
240
+ const now = Date.now()
241
+ const container = event.currentTarget as HTMLElement
130
242
 
131
- // Check if the touch started on an interactive element
132
- if (targetElement.closest('button, a, input, textarea, select')) {
133
- return // Don't handle swipe if interacting with an interactive element
243
+ // Handle double tap detection for zoom
244
+ if (now - lastTapTime.value < 300 && event.touches.length === 1) {
245
+ if (scale.value !== 1) {
246
+ // Reset zoom and pan on double tap if already zoomed
247
+ scale.value = 1
248
+ panPosition.x = 0
249
+ panPosition.y = 0
250
+ }
251
+ else {
252
+ // Zoom to a moderate level on double tap
253
+ scale.value = 2.5
254
+ // Center zoom on tap location
255
+ const touch = event.touches[0]
256
+ const rect = container.getBoundingClientRect()
257
+ touchCenter.x = (touch.clientX - rect.left) / rect.width
258
+ touchCenter.y = (touch.clientY - rect.top) / rect.height
259
+ }
260
+ event.preventDefault()
261
+ return
134
262
  }
135
263
 
136
- start.x = touch.screenX
137
- start.y = touch.screenY
264
+ lastTapTime.value = now
265
+
266
+ // Detect if we're starting to pinch zoom
267
+ if (event.touches.length === 2) {
268
+ isPinching.value = true
269
+ startDistance.value = getDistance(event.touches[0], event.touches[1])
270
+ const center = getTouchCenter(event.touches[0], event.touches[1], container)
271
+ touchCenter.x = center.x
272
+ touchCenter.y = center.y
273
+ event.preventDefault()
274
+ return
275
+ }
276
+
277
+ // If we're already zoomed in, enable panning with one finger
278
+ if (scale.value > 1 && event.touches.length === 1) {
279
+ isPanning.value = true
280
+ startPanPosition.x = panPosition.x
281
+ startPanPosition.y = panPosition.y
282
+ start.x = event.touches[0].clientX
283
+ start.y = event.touches[0].clientY
284
+ event.preventDefault()
285
+ return
286
+ }
287
+
288
+ // Normal swipe behavior if not zoomed
289
+ if (scale.value <= 1 && event.touches.length === 1) {
290
+ const touch = event.touches[0]
291
+ const targetElement = touch.target as HTMLElement
292
+
293
+ // Check if the touch started on an interactive element
294
+ if (targetElement.closest('button, a, input, textarea, select')) {
295
+ return // Don't handle swipe if interacting with an interactive element
296
+ }
297
+
298
+ start.x = touch.screenX
299
+ start.y = touch.screenY
300
+ }
138
301
  }
302
+
303
+ function touchMove(event: TouchEvent) {
304
+ // Handle pinch zoom
305
+ if (isPinching.value && event.touches.length === 2) {
306
+ const currentDistance = getDistance(event.touches[0], event.touches[1])
307
+ const newScale = (currentDistance / startDistance.value) * scale.value
308
+
309
+ // Limit zoom range
310
+ scale.value = Math.min(Math.max(0.5, newScale), 5)
311
+ event.preventDefault()
312
+ return
313
+ }
314
+
315
+ // Handle panning when zoomed in
316
+ if (isPanning.value && event.touches.length === 1) {
317
+ const deltaX = event.touches[0].clientX - start.x
318
+ const deltaY = event.touches[0].clientY - start.y
319
+
320
+ // Calculate how much the pan should be constrained based on zoom level
321
+ const constraint = Math.max(0, scale.value - 1) * 150
322
+
323
+ panPosition.x = Math.min(Math.max(startPanPosition.x + deltaX, -constraint), constraint)
324
+ panPosition.y = Math.min(Math.max(startPanPosition.y + deltaY, -constraint), constraint)
325
+
326
+ event.preventDefault()
327
+ }
328
+ }
329
+
139
330
  function touchEnd(event: TouchEvent) {
140
- const touch = event.changedTouches[0]
141
- const targetElement = touch.target as HTMLElement
331
+ // Reset pinch zoom state
332
+ if (isPinching.value) {
333
+ isPinching.value = false
334
+ return
335
+ }
142
336
 
143
- // Check if the touch ended on an interactive element
144
- if (targetElement.closest('button, a, input, textarea, select')) {
145
- return // Don't handle swipe if interacting with an interactive element
337
+ // Reset panning state
338
+ if (isPanning.value) {
339
+ isPanning.value = false
340
+ return
146
341
  }
147
342
 
148
- const end = { x: touch.screenX, y: touch.screenY }
343
+ // If we're zoomed in, don't handle swiping between images
344
+ if (scale.value > 1) {
345
+ return
346
+ }
149
347
 
150
- const diffX = start.x - end.x
151
- const diffY = start.y - end.y
348
+ // Normal swipe behavior if not zoomed
349
+ if (event.changedTouches.length === 1) {
350
+ const touch = event.changedTouches[0]
351
+ const targetElement = touch.target as HTMLElement
152
352
 
153
- // Add a threshold to prevent accidental swipes
154
- if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
155
- if (diffX > 0) {
156
- direction.value = 'next'
157
- goNextImage()
353
+ // Check if the touch ended on an interactive element
354
+ if (targetElement.closest('button, a, input, textarea, select')) {
355
+ return // Don't handle swipe if interacting with an interactive element
158
356
  }
159
- else {
160
- direction.value = 'prev'
161
- goPrevImage()
357
+
358
+ const end = { x: touch.screenX, y: touch.screenY }
359
+
360
+ const diffX = start.x - end.x
361
+ const diffY = start.y - end.y
362
+
363
+ // Add a threshold to prevent accidental swipes
364
+ if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
365
+ if (diffX > 0) {
366
+ direction.value = 'next'
367
+ goNextImage()
368
+ }
369
+ else {
370
+ direction.value = 'prev'
371
+ goPrevImage()
372
+ }
162
373
  }
163
374
  }
164
375
  }
@@ -178,8 +389,25 @@ function handleKeyboardInput(event: KeyboardEvent) {
178
389
 
179
390
  switch (event.key) {
180
391
  case 'Escape':
181
- event.preventDefault()
182
- setModal(false)
392
+ if (scale.value > 1) {
393
+ // If zoomed in, reset zoom first
394
+ scale.value = 1
395
+ panPosition.x = 0
396
+ panPosition.y = 0
397
+ }
398
+ else if (showMobileThumbnails.value) {
399
+ // If mobile thumbnails are shown, hide them first
400
+ showMobileThumbnails.value = false
401
+ }
402
+ else if (isFullscreen.value) {
403
+ // If in fullscreen, exit fullscreen first
404
+ isFullscreen.value = false
405
+ }
406
+ else {
407
+ // Otherwise close the gallery
408
+ event.preventDefault()
409
+ setModal(false)
410
+ }
183
411
  break
184
412
  case 'ArrowRight':
185
413
  isKeyPressed.value = true
@@ -191,6 +419,9 @@ function handleKeyboardInput(event: KeyboardEvent) {
191
419
  direction.value = 'prev'
192
420
  goPrevImage()
193
421
  break
422
+ case 'f':
423
+ toggleFullscreen()
424
+ break
194
425
  default:
195
426
  break
196
427
  }
@@ -202,6 +433,10 @@ function handleKeyboardRelease(event: KeyboardEvent) {
202
433
  }
203
434
  }
204
435
 
436
+ function onImageLoad() {
437
+ isImageLoading.value = false
438
+ }
439
+
205
440
  function closeGallery() {
206
441
  setModal(false)
207
442
  }
@@ -213,6 +448,21 @@ function handleBackdropClick(event: MouseEvent) {
213
448
  }
214
449
  }
215
450
 
451
+ // Toggle mobile thumbnails panel
452
+ function toggleMobileThumbnails() {
453
+ showMobileThumbnails.value = !showMobileThumbnails.value
454
+ }
455
+
456
+ // Computed transform style for zoom and pan
457
+ const imageTransform = computed(() => {
458
+ return `scale(${scale.value}) translate(${panPosition.x / scale.value}px, ${panPosition.y / scale.value}px)`
459
+ })
460
+
461
+ // Transform origin for zoom based on touch position
462
+ const imageTransformOrigin = computed(() => {
463
+ return `${touchCenter.x * 100}% ${touchCenter.y * 100}%`
464
+ })
465
+
216
466
  onMounted(() => {
217
467
  eventBus.on(`${props.id}GalleryImage`, openGalleryImage)
218
468
  eventBus.on(`${props.id}Gallery`, openGalleryImage)
@@ -226,6 +476,7 @@ onUnmounted(() => {
226
476
  if (!import.meta.env.SSR) {
227
477
  document.removeEventListener('keydown', handleKeyboardInput)
228
478
  document.removeEventListener('keyup', handleKeyboardRelease)
479
+ document.removeEventListener('keydown', handleTabKey)
229
480
  document.body.style.overflow = '' // Ensure body scrolling is restored
230
481
  }
231
482
  })
@@ -243,58 +494,100 @@ onUnmounted(() => {
243
494
  >
244
495
  <div
245
496
  v-if="isGalleryOpen"
246
- class="fixed bg-fv-neutral-900 text-white inset-0 max-w-[100vw] overflow-y-auto overflow-x-hidden"
497
+ ref="galleryRef"
498
+ class="fixed bg-gradient-to-b from-fv-neutral-950 to-fv-neutral-900 text-white inset-0 max-w-[100vw] overflow-hidden"
499
+ :class="{ 'fullscreen-mode': isFullscreen }"
247
500
  style="z-index: 37"
248
501
  role="dialog"
249
502
  aria-modal="true"
250
503
  @click="handleBackdropClick"
251
504
  >
252
505
  <div
253
- class="relative w-full max-w-full flex flex-col justify-center items-center"
506
+ class="relative w-full max-w-full flex flex-col justify-center items-center h-full"
254
507
  style="z-index: 38"
255
508
  @click.stop
256
509
  >
257
- <div class="flex flex-grow gap-4 w-full max-w-full">
258
- <div class="flex-grow h-[100vh] flex items-center relative">
259
- <button
260
- class="btn w-9 h-9 rounded-full absolute top-4 left-2"
261
- style="z-index: 39"
262
- aria-label="Close gallery"
263
- @click="setModal(false)"
264
- >
265
- <component :is="closeIcon" class="w-8 h-8" />
266
- </button>
510
+ <div class="flex flex-grow gap-4 w-full max-w-full h-full">
511
+ <div class="flex-grow h-full flex items-center relative">
512
+ <!-- Top control bar -->
513
+ <div class="absolute top-0 left-0 right-0 flex justify-between items-center p-4 bg-gradient-to-b from-black/50 to-transparent z-[40]">
514
+ <div class="flex items-center gap-2">
515
+ <button
516
+ aria-label="Close gallery"
517
+ class="rounded-full p-2 bg-black/40 backdrop-blur-sm hover:bg-black/60 transition-colors"
518
+ @click="setModal(false)"
519
+ >
520
+ <component :is="closeIcon" class="w-6 h-6" />
521
+ </button>
522
+
523
+ <!-- Mobile thumbnails toggle button -->
524
+ <button
525
+ v-if="images.length > 1"
526
+ aria-label="Toggle thumbnails"
527
+ class="sm:hidden rounded-full p-2 bg-black/40 backdrop-blur-sm hover:bg-black/60 transition-colors"
528
+ @click="toggleMobileThumbnails"
529
+ >
530
+ <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
531
+ <path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
532
+ </svg>
533
+ </button>
534
+
535
+ <!-- Fullscreen toggle button -->
536
+ <button
537
+ aria-label="Toggle fullscreen"
538
+ class="rounded-full p-2 bg-black/40 backdrop-blur-sm hover:bg-black/60 transition-colors"
539
+ @click="toggleFullscreen"
540
+ >
541
+ <ArrowsPointingOutIcon class="w-5 h-5" />
542
+ </button>
543
+ </div>
544
+
545
+ <div class="px-3 py-1 rounded-full bg-black/40 backdrop-blur-sm text-white text-sm">
546
+ {{ imageCounter }}
547
+ </div>
548
+ </div>
267
549
 
268
550
  <div
269
- class="flex h-[100vh] relative flex-grow items-center justify-center gap-2 z-[1]"
551
+ class="flex h-full relative flex-grow items-center justify-center gap-2 z-[1]"
270
552
  >
553
+ <!-- Left nav button (larger touch area on mobile) -->
271
554
  <div
272
- class="hidden lg:relative z-[2] lg:flex w-10 flex-shrink-0 items-center justify-center flex-0"
555
+ class="absolute left-0 top-0 bottom-0 w-1/3 flex items-center justify-start pl-4 lg:pl-10 z-[2]"
556
+ @click="scale <= 1 && goPrevImage()"
273
557
  >
274
558
  <button
275
559
  v-if="images.length > 1"
276
- class="btn p-1 rounded-full"
277
560
  aria-label="Previous image"
278
- @click="goPrevImage()"
561
+ class="rounded-full p-2 bg-black/40 backdrop-blur-sm hover:bg-black/60 transition-colors hidden sm:block"
562
+ @click.stop
279
563
  >
280
- <ArrowLeftCircleIcon class="w-8 h-8" />
564
+ <ArrowLeftCircleIcon class="w-6 h-6" />
281
565
  </button>
282
566
  </div>
567
+
283
568
  <div
284
569
  class="flex-1 flex flex-col z-[2] items-center justify-center max-w-full lg:max-w-[calc(100vw - 256px)] relative"
285
570
  >
571
+ <!-- Loading indicator -->
572
+ <div
573
+ v-if="isImageLoading"
574
+ class="absolute inset-0 flex items-center justify-center z-10 bg-fv-neutral-900/30 backdrop-blur-sm"
575
+ >
576
+ <div class="w-10 h-10 border-t-2 border-white rounded-full animate-spin" />
577
+ </div>
578
+
286
579
  <transition
287
580
  :name="direction === 'next' ? 'slide-next' : 'slide-prev'"
288
581
  mode="out-in"
289
582
  >
290
583
  <div
291
- v-if="true"
292
584
  :key="`image-display-${modelValue}`"
293
585
  class="flex-1 w-full max-w-full flex flex-col items-center justify-center absolute inset-0 z-[2]"
294
586
  >
295
587
  <div
296
- class="flex-1 w-full max-w-full flex items-center justify-center"
588
+ class="flex-1 w-full max-w-full flex items-center justify-center overflow-hidden"
297
589
  @touchstart="touchStart"
590
+ @touchmove="touchMove"
298
591
  @touchend="touchEnd"
299
592
  >
300
593
  <template
@@ -304,63 +597,89 @@ onUnmounted(() => {
304
597
  <component
305
598
  :is="videoComponent"
306
599
  :src="isVideo(images[modelValue])"
307
- class="shadow max-w-full h-auto object-contain max-h-[85vh]"
600
+ class="shadow-2xl max-w-full h-auto object-contain max-h-[85vh]"
308
601
  />
309
602
  </ClientOnly>
310
603
  </template>
311
604
  <template v-else>
312
- <img
313
- v-if="modelValueSrc && imageComponent === 'img'"
314
- class="shadow max-w-full h-auto object-contain max-h-[85vh]"
315
- :src="modelValueSrc"
316
- :alt="`Gallery image ${modelValue + 1}`"
317
- >
318
- <component
319
- :is="imageComponent"
320
- v-else-if="modelValueSrc && imageComponent"
321
- :image="modelValueSrc.image"
322
- :variant="modelValueSrc.variant"
323
- :alt="modelValueSrc.alt"
324
- class="shadow max-w-full h-auto object-contain max-h-[85vh]"
325
- />
605
+ <div class="image-container">
606
+ <img
607
+ v-if="modelValueSrc && imageComponent === 'img'"
608
+ class="shadow-2xl max-w-full h-auto object-contain max-h-[80vh] transition-transform duration-100 rounded-md"
609
+ :class="{ 'cursor-grab': scale > 1, 'cursor-zoom-in': scale === 1 }"
610
+ :style="{
611
+ transform: imageTransform,
612
+ transformOrigin: imageTransformOrigin,
613
+ touchAction: scale > 1 ? 'none' : 'auto',
614
+ }"
615
+ :src="modelValueSrc"
616
+ :alt="`Gallery image ${modelValue + 1}`"
617
+ @load="onImageLoad"
618
+ >
619
+ <component
620
+ :is="imageComponent"
621
+ v-else-if="modelValueSrc && imageComponent"
622
+ :image="modelValueSrc.image"
623
+ :variant="modelValueSrc.variant"
624
+ :alt="modelValueSrc.alt"
625
+ class="shadow-2xl max-w-full h-auto object-contain max-h-[80vh] transition-transform duration-100 rounded-md"
626
+ :style="{
627
+ transform: imageTransform,
628
+ transformOrigin: imageTransformOrigin,
629
+ touchAction: scale > 1 ? 'none' : 'auto',
630
+ }"
631
+ @load="onImageLoad"
632
+ />
633
+ </div>
326
634
  </template>
327
635
  </div>
636
+
637
+ <!-- Mobile zoom hint -->
328
638
  <div
329
- class="flex-0 py-2 flex items-center justify-center max-w-full w-full relative !z-[3]"
639
+ v-if="scale === 1 && !isImageLoading"
640
+ class="text-xs text-white/60 p-2 sm:hidden"
641
+ >
642
+ Double-tap to zoom
643
+ </div>
644
+
645
+ <div
646
+ class="flex-0 py-4 flex items-center justify-center max-w-full w-full relative !z-[3]"
330
647
  >
331
648
  <slot :value="images[modelValue]" />
332
649
  </div>
333
650
  </div>
334
651
  </transition>
335
652
  </div>
653
+
654
+ <!-- Right nav button (larger touch area on mobile) -->
336
655
  <div
337
- class="hidden lg:flex w-10 flex-shrink-0 items-center justify-center"
656
+ class="absolute right-0 top-0 bottom-0 w-1/3 flex items-center justify-end pr-4 lg:pr-10 z-[2]"
657
+ @click="scale <= 1 && goNextImage()"
338
658
  >
339
- <button
340
- class="btn w-9 h-9 rounded-full hidden lg:block absolute top-4"
341
- :class="{
342
- '-right-4': sidePanel,
343
- 'right-2': !sidePanel,
344
- }"
345
- style="z-index: 39"
346
- :aria-label="sidePanel ? 'Hide thumbnails' : 'Show thumbnails'"
347
- @click="() => (sidePanel = !sidePanel)"
348
- >
349
- <ChevronDoubleRightIcon v-if="sidePanel" class="w-7 h-7" />
350
- <ChevronDoubleLeftIcon v-else class="w-7 h-7" />
351
- </button>
352
659
  <button
353
660
  v-if="images.length > 1"
354
- class="btn p-1 rounded-full"
355
661
  aria-label="Next image"
356
- @click="goNextImage()"
662
+ class="rounded-full p-2 bg-black/40 backdrop-blur-sm hover:bg-black/60 transition-colors hidden sm:block"
663
+ @click.stop
357
664
  >
358
- <ArrowRightCircleIcon class="w-8 h-8" />
665
+ <ArrowRightCircleIcon class="w-6 h-6" />
359
666
  </button>
360
667
  </div>
668
+
669
+ <!-- Sidebar toggle button (desktop) -->
670
+ <button
671
+ class="absolute top-4 right-4 rounded-full p-2 bg-black/40 backdrop-blur-sm hover:bg-black/60 transition-colors hidden lg:block"
672
+ style="z-index: 39"
673
+ :aria-label="sidePanel ? 'Hide thumbnails' : 'Show thumbnails'"
674
+ @click="() => (sidePanel = !sidePanel)"
675
+ >
676
+ <ChevronDoubleRightIcon v-if="sidePanel" class="w-5 h-5" />
677
+ <ChevronDoubleLeftIcon v-else class="w-5 h-5" />
678
+ </button>
361
679
  </div>
362
680
  </div>
363
681
 
682
+ <!-- Sidebar with thumbnails (desktop) -->
364
683
  <transition
365
684
  enter-active-class="transform transition ease-in-out duration-300"
366
685
  enter-from-class="translate-x-full"
@@ -371,59 +690,162 @@ onUnmounted(() => {
371
690
  >
372
691
  <div
373
692
  v-if="sidePanel"
374
- class="hidden lg:block flex-shrink-0 w-64 bg-fv-neutral-800 h-[100vh] max-h-[100vh] overflow-y-auto"
693
+ class="hidden lg:block flex-shrink-0 w-64 bg-fv-neutral-800/80 backdrop-blur-sm h-full max-h-full overflow-y-auto border-l border-fv-neutral-700/50"
375
694
  >
376
- <!-- Side panel content -->
377
- <div v-if="paging" class="flex items-center justify-center">
695
+ <!-- Sidebar header -->
696
+ <div class="p-4 border-b border-fv-neutral-700/50">
697
+ <h3 class="text-lg font-medium">
698
+ Gallery
699
+ </h3>
700
+ </div>
701
+
702
+ <!-- Paging controls if provided -->
703
+ <div v-if="paging" class="flex items-center justify-center p-2 border-b border-fv-neutral-700/50">
378
704
  <DefaultPaging :id="id" :items="paging" />
379
705
  </div>
380
- <div class="grid grid-cols-2 gap-2 p-2">
706
+
707
+ <!-- Thumbnails grid -->
708
+ <div class="grid grid-cols-2 gap-2 p-4">
381
709
  <div
382
710
  v-for="i in images.length"
383
711
  :key="`bg_${id}_${i}`"
384
- class="hover:!brightness-100"
385
- :style="{
386
- filter:
387
- i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.5)',
388
- }"
712
+ class="relative group"
389
713
  >
714
+ <!-- Active image indicator -->
715
+ <div
716
+ v-if="i - 1 === modelValue"
717
+ class="absolute inset-0 border-2 border-white rounded-lg z-10"
718
+ />
719
+
720
+ <!-- Thumbnail -->
721
+ <div
722
+ class="overflow-hidden rounded-lg transition-all duration-200"
723
+ :class="{ 'ring-2 ring-white': i - 1 === modelValue, 'opacity-60 group-hover:opacity-100': i - 1 !== modelValue }"
724
+ >
725
+ <img
726
+ v-if="imageComponent === 'img'"
727
+ class="h-auto max-w-full rounded-lg cursor-pointer shadow transform transition duration-300 group-hover:scale-105 group-hover:brightness-110"
728
+ :src="getThumbnailUrl(images[i - 1])"
729
+ :alt="`Thumbnail ${i}`"
730
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
731
+ >
732
+ <component
733
+ :is="imageComponent"
734
+ v-else
735
+ :image="getThumbnailUrl(images[i - 1]).image"
736
+ :variant="getThumbnailUrl(images[i - 1]).variant"
737
+ :alt="getThumbnailUrl(images[i - 1]).alt"
738
+ :class="`h-auto max-w-full rounded-lg cursor-pointer shadow transform transition duration-300 group-hover:scale-105 group-hover:brightness-110 ${getBorderColor(images[i - 1])}`"
739
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
740
+ />
741
+ </div>
742
+ </div>
743
+ </div>
744
+ </div>
745
+ </transition>
746
+ </div>
747
+
748
+ <!-- Mobile navigation controls (bottom bar) -->
749
+ <div
750
+ v-if="!showMobileThumbnails"
751
+ class="fixed bottom-0 left-0 right-0 sm:hidden flex justify-between items-center p-4 bg-gradient-to-t from-black/80 to-transparent"
752
+ style="z-index: 40"
753
+ >
754
+ <button
755
+ v-if="images.length > 1 && scale <= 1"
756
+ aria-label="Previous image"
757
+ class="rounded-full p-3 bg-black/50 backdrop-blur-sm"
758
+ @click="goPrevImage"
759
+ >
760
+ <ArrowLeftCircleIcon class="w-6 h-6" />
761
+ </button>
762
+
763
+ <!-- Reset zoom button when zoomed -->
764
+ <button
765
+ v-if="scale > 1"
766
+ aria-label="Reset zoom"
767
+ class="rounded-full p-2 bg-black/50 backdrop-blur-sm"
768
+ @click="scale = 1; panPosition.x = 0; panPosition.y = 0"
769
+ >
770
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
771
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
772
+ </svg>
773
+ </button>
774
+
775
+ <button
776
+ v-if="images.length > 1 && scale <= 1"
777
+ aria-label="Next image"
778
+ class="rounded-full p-3 bg-black/50 backdrop-blur-sm"
779
+ @click="goNextImage"
780
+ >
781
+ <ArrowRightCircleIcon class="w-6 h-6" />
782
+ </button>
783
+ </div>
784
+
785
+ <!-- Mobile thumbnails panel -->
786
+ <transition
787
+ enter-active-class="transform transition ease-in-out duration-300"
788
+ enter-from-class="translate-y-full"
789
+ enter-to-class="translate-y-0"
790
+ leave-active-class="transform transition ease-in-out duration-300"
791
+ leave-from-class="translate-y-0"
792
+ leave-to-class="translate-y-full"
793
+ >
794
+ <div
795
+ v-if="showMobileThumbnails"
796
+ class="fixed bottom-0 left-0 right-0 sm:hidden bg-black/80 backdrop-blur-md pt-4 pb-6 border-t border-fv-neutral-700/50"
797
+ style="z-index: 50"
798
+ >
799
+ <div class="relative">
800
+ <!-- Close thumbnails button -->
801
+ <button
802
+ aria-label="Close thumbnails"
803
+ class="absolute top-0 right-4 rounded-full p-1 bg-black/40 hover:bg-black/60"
804
+ @click="showMobileThumbnails = false"
805
+ >
806
+ <XCircleIcon class="w-5 h-5" />
807
+ </button>
808
+
809
+ <p class="text-center text-sm mb-2 font-medium">
810
+ Thumbnails
811
+ </p>
812
+
813
+ <!-- Horizontal scrollable thumbnails -->
814
+ <div class="flex overflow-x-auto px-4 pb-2 space-x-2 hide-scrollbar">
815
+ <div
816
+ v-for="i in images.length"
817
+ :key="`mt_${id}_${i}`"
818
+ class="flex-shrink-0 w-16 h-16 relative"
819
+ :class="{ 'opacity-100': i - 1 === modelValue, 'opacity-50': i - 1 !== modelValue }"
820
+ >
821
+ <div
822
+ v-if="i - 1 === modelValue"
823
+ class="absolute inset-0 border-2 border-white rounded-lg z-10"
824
+ />
390
825
  <img
391
- v-if="imageComponent === 'img'"
392
- :class="`h-auto max-w-full rounded-lg cursor-pointer shadow ${getBorderColor(
393
- images[i - 1],
394
- )}`"
826
+ class="h-full w-full object-cover rounded-lg"
395
827
  :src="getThumbnailUrl(images[i - 1])"
396
828
  :alt="`Thumbnail ${i}`"
397
- @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
829
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1); showMobileThumbnails = false"
398
830
  >
399
- <component
400
- :is="imageComponent"
401
- v-else
402
- :image="getThumbnailUrl(images[i - 1]).image"
403
- :variant="getThumbnailUrl(images[i - 1]).variant"
404
- :alt="getThumbnailUrl(images[i - 1]).alt"
405
- :class="`h-auto max-w-full rounded-lg cursor-pointer shadow ${getBorderColor(
406
- images[i - 1],
407
- )}`"
408
- @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
409
- />
410
831
  </div>
411
832
  </div>
412
833
  </div>
413
- </transition>
414
- </div>
834
+ </div>
835
+ </transition>
415
836
  </div>
416
837
  </div>
417
838
  </transition>
418
839
 
840
+ <!-- Grid view mode -->
419
841
  <div v-if="mode === 'grid' || mode === 'mason' || mode === 'custom'" class="min-h-[600px]">
420
842
  <div
421
843
  :class="{
422
- ' grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 items-start':
844
+ 'grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 items-start':
423
845
  mode === 'mason',
424
- ' grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 items-center':
846
+ 'grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 items-center':
425
847
  mode === 'grid',
426
- ' custom-grid': mode === 'custom',
848
+ 'custom-grid': mode === 'custom',
427
849
  }"
428
850
  >
429
851
  <slot name="thumbnail" />
@@ -438,10 +860,19 @@ onUnmounted(() => {
438
860
  </div>
439
861
 
440
862
  <template v-for="j in gridHeight" :key="`gi_${id}_${i + j}`">
441
- <div>
863
+ <div class="group overflow-hidden rounded-lg relative">
864
+ <!-- Visual feedback for the tap action on mobile -->
865
+ <div class="absolute inset-0 bg-black/30 opacity-0 transition-opacity duration-300 flex items-center justify-center z-[1] group-hover-opacity-100 group-active-opacity-100">
866
+ <div class="bg-black/60 rounded-full p-2 transform scale-0 transition-transform duration-300 group-hover-scale-100 group-active-scale-100">
867
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
868
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
869
+ </svg>
870
+ </div>
871
+ </div>
872
+
442
873
  <img
443
874
  v-if="i + j - 2 < images.length && imageComponent === 'img'"
444
- class="h-auto max-w-full rounded-lg cursor-pointer"
875
+ class="h-auto max-w-full rounded-lg cursor-pointer transform transition-transform duration-300 group-hover-scale-105 group-active-scale-102"
445
876
  :src="getThumbnailUrl(images[i + j - 2])"
446
877
  :alt="`Gallery image ${i + j - 1}`"
447
878
  @click="$eventBus.emit(`${id}GalleryImage`, i + j - 2)"
@@ -452,9 +883,7 @@ onUnmounted(() => {
452
883
  :image="getThumbnailUrl(images[i + j - 2]).image"
453
884
  :variant="getThumbnailUrl(images[i + j - 2]).variant"
454
885
  :alt="getThumbnailUrl(images[i + j - 2]).alt"
455
- :class="`h-auto max-w-full rounded-lg cursor-pointer ${getBorderColor(
456
- images[i + j - 2],
457
- )}`"
886
+ :class="`h-auto max-w-full rounded-lg cursor-pointer transform transition-transform duration-300 group-hover-scale-105 group-active-scale-102 ${getBorderColor(images[i + j - 2])}`"
458
887
  :likes="getThumbnailUrl(images[i + j - 2]).likes"
459
888
  :show-likes="getThumbnailUrl(images[i + j - 2]).showLikes"
460
889
  :is-author="getThumbnailUrl(images[i + j - 2]).isAuthor"
@@ -465,13 +894,23 @@ onUnmounted(() => {
465
894
  </template>
466
895
  </div>
467
896
  </template>
468
- <div v-else class="relative">
469
- <div v-if="ranking" class="img-gallery-ranking">
897
+ <div v-else class="relative group overflow-hidden rounded-lg">
898
+ <div v-if="ranking" class="img-gallery-ranking z-10">
470
899
  {{ i }}
471
900
  </div>
901
+
902
+ <!-- Visual overlay on hover with touch feedback -->
903
+ <div class="absolute inset-0 bg-black/30 opacity-0 transition-opacity duration-300 flex items-center justify-center z-[1] group-hover-opacity-100 group-active-opacity-100">
904
+ <div class="bg-black/60 rounded-full p-2 transform scale-0 transition-transform duration-300 group-hover-scale-100 group-active-scale-100">
905
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
906
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
907
+ </svg>
908
+ </div>
909
+ </div>
910
+
472
911
  <img
473
912
  v-if="imageComponent === 'img'"
474
- class="h-auto max-w-full rounded-lg cursor-pointer"
913
+ class="h-auto max-w-full rounded-lg cursor-pointer transform transition-transform duration-300 group-hover-scale-105 group-active-scale-102"
475
914
  :src="getThumbnailUrl(images[i - 1])"
476
915
  :alt="`Gallery image ${i}`"
477
916
  @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
@@ -482,9 +921,7 @@ onUnmounted(() => {
482
921
  :image="getThumbnailUrl(images[i - 1]).image"
483
922
  :variant="getThumbnailUrl(images[i - 1]).variant"
484
923
  :alt="getThumbnailUrl(images[i - 1]).alt"
485
- :class="`h-auto max-w-full rounded-lg cursor-pointer ${getBorderColor(
486
- images[i - 1],
487
- )}`"
924
+ :class="`h-auto max-w-full rounded-lg cursor-pointer transform transition-transform duration-300 group-hover-scale-105 group-active-scale-102 ${getBorderColor(images[i - 1])}`"
488
925
  :likes="getThumbnailUrl(images[i - 1]).likes"
489
926
  :show-likes="getThumbnailUrl(images[i - 1]).showLikes"
490
927
  :is-author="getThumbnailUrl(images[i - 1]).isAuthor"
@@ -495,11 +932,16 @@ onUnmounted(() => {
495
932
  </template>
496
933
  </div>
497
934
  </div>
935
+
936
+ <!-- Button mode -->
498
937
  <button
499
938
  v-if="mode === 'button'"
500
- :class="`btn ${buttonType ? buttonType : 'primary'} defaults`"
939
+ :class="`btn ${buttonType ? buttonType : 'primary'} defaults flex items-center gap-2`"
501
940
  @click="openGalleryImage(0)"
502
941
  >
942
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
943
+ <path fill-rule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd" />
944
+ </svg>
503
945
  {{ buttonText ? buttonText : $t("open_gallery_cta") }}
504
946
  </button>
505
947
  </div>
@@ -510,9 +952,9 @@ onUnmounted(() => {
510
952
  .slide-next-enter-active,
511
953
  .slide-next-leave-active {
512
954
  transition:
513
- opacity 0.15s,
514
- transform 0.15s,
515
- filter 0.15s;
955
+ opacity 0.3s,
956
+ transform 0.3s,
957
+ filter 0.3s;
516
958
  }
517
959
 
518
960
  .slide-next-enter-from {
@@ -543,9 +985,9 @@ onUnmounted(() => {
543
985
  .slide-prev-enter-active,
544
986
  .slide-prev-leave-active {
545
987
  transition:
546
- opacity 0.15s,
547
- transform 0.15s,
548
- filter 0.15s;
988
+ opacity 0.3s,
989
+ transform 0.3s,
990
+ filter 0.3s;
549
991
  }
550
992
 
551
993
  .slide-prev-enter-from {
@@ -572,18 +1014,86 @@ onUnmounted(() => {
572
1014
  filter: blur(10px);
573
1015
  }
574
1016
 
575
- /* Ensure the images are positioned correctly to prevent overlap */
576
- .relative-container {
577
- position: relative;
578
- width: 100%;
579
- height: 100%;
1017
+ /* Custom animation for loading spinner */
1018
+ @keyframes spin {
1019
+ from { transform: rotate(0deg); }
1020
+ to { transform: rotate(360deg); }
580
1021
  }
581
1022
 
582
- .relative-container > div {
1023
+ .animate-spin {
1024
+ animation: spin 1s linear infinite;
1025
+ }
1026
+
1027
+ /* Improved gallery ranking */
1028
+ .img-gallery-ranking {
583
1029
  position: absolute;
584
- top: 0;
585
- left: 0;
1030
+ top: 0.5rem;
1031
+ left: 0.5rem;
1032
+ width: 1.5rem;
1033
+ height: 1.5rem;
1034
+ display: flex;
1035
+ align-items: center;
1036
+ justify-content: center;
1037
+ background-color: rgba(0, 0, 0, 0.7);
1038
+ color: white;
1039
+ border-radius: 9999px;
1040
+ font-size: 0.75rem;
1041
+ font-weight: 600;
1042
+ z-index: 5;
1043
+ }
1044
+
1045
+ /* Image container */
1046
+ .image-container {
1047
+ display: flex;
1048
+ align-items: center;
1049
+ justify-content: center;
586
1050
  width: 100%;
587
1051
  height: 100%;
1052
+ user-select: none;
1053
+ touch-action: none;
1054
+ }
1055
+
1056
+ /* Hide scrollbar for mobile thumbnails */
1057
+ .hide-scrollbar {
1058
+ -ms-overflow-style: none; /* IE and Edge */
1059
+ scrollbar-width: none; /* Firefox */
1060
+ }
1061
+
1062
+ .hide-scrollbar::-webkit-scrollbar {
1063
+ display: none; /* Chrome, Safari, Opera */
1064
+ }
1065
+
1066
+ /* Use direct CSS for hover/active states - avoid @apply for these */
1067
+ .group:hover .group-hover-opacity-100 {
1068
+ opacity: 1;
1069
+ }
1070
+
1071
+ .group:hover .group-hover-scale-100 {
1072
+ transform: scale(1);
1073
+ }
1074
+
1075
+ .group:hover .group-hover-scale-105 {
1076
+ transform: scale(1.05);
1077
+ }
1078
+
1079
+ .group:active .group-active-opacity-100 {
1080
+ opacity: 1;
1081
+ }
1082
+
1083
+ .group:active .group-active-scale-100 {
1084
+ transform: scale(1);
1085
+ }
1086
+
1087
+ .group:active .group-active-scale-102 {
1088
+ transform: scale(1.02);
1089
+ }
1090
+
1091
+ /* Fullscreen mode */
1092
+ .fullscreen-mode {
1093
+ position: fixed;
1094
+ inset: 0;
1095
+ width: 100vw;
1096
+ height: 100vh;
1097
+ z-index: 99;
588
1098
  }
589
1099
  </style>
@@ -3,6 +3,9 @@ import { XCircleIcon } from '@heroicons/vue/24/solid'
3
3
  import { h, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
4
4
  import { useEventBus } from '../../composables/event-bus'
5
5
 
6
+ // Static counter for managing z-index across all modals
7
+ let modalCounter = 0
8
+
6
9
  const props = withDefaults(
7
10
  defineProps<{
8
11
  id: string
@@ -27,6 +30,10 @@ const modalRef = ref<HTMLElement | null>(null)
27
30
  let previouslyFocusedElement: HTMLElement | null = null
28
31
  let focusableElements: HTMLElement[] = []
29
32
 
33
+ // Dynamic z-index to ensure the most recently opened modal is on top
34
+ const baseZIndex = 100 // Use a higher base z-index to avoid conflicts
35
+ const zIndex = ref<number>(baseZIndex)
36
+
30
37
  // Trap focus within modal for accessibility
31
38
  function getFocusableElements(element: HTMLElement): HTMLElement[] {
32
39
  return Array.from(
@@ -39,8 +46,16 @@ function getFocusableElements(element: HTMLElement): HTMLElement[] {
39
46
  }
40
47
 
41
48
  function handleKeyDown(event: KeyboardEvent) {
49
+ // Only handle events for the top-most modal
42
50
  if (!isOpen.value) return
43
51
 
52
+ // Get all active modals
53
+ const activeModals = document.querySelectorAll('[data-modal-active="true"]')
54
+ // If this is not the top-most modal, don't handle keyboard events
55
+ if (activeModals.length > 0 && modalRef.value !== activeModals[activeModals.length - 1]) {
56
+ return
57
+ }
58
+
44
59
  // Close on escape
45
60
  if (event.key === 'Escape') {
46
61
  event.preventDefault()
@@ -67,12 +82,28 @@ function setModal(value: boolean) {
67
82
  if (value === true) {
68
83
  if (props.onOpen) props.onOpen()
69
84
  previouslyFocusedElement = document.activeElement as HTMLElement
70
- document.body.style.overflow = 'hidden' // Prevent scrolling when modal is open
85
+
86
+ // Set this modal's z-index higher than any existing modal
87
+ modalCounter += 3 // Increase by 3 to handle backdrop, container and content
88
+ zIndex.value = baseZIndex + modalCounter
89
+
90
+ // Only manage body overflow for the first opened modal
91
+ const activeModals = document.querySelectorAll('[data-modal-active="true"]')
92
+ if (activeModals.length === 0) {
93
+ document.body.style.overflow = 'hidden' // Prevent scrolling when modal is open
94
+ }
95
+
71
96
  document.addEventListener('keydown', handleKeyDown)
72
97
  }
73
98
  if (value === false) {
74
99
  if (props.onClose) props.onClose()
75
- document.body.style.overflow = '' // Restore scrolling
100
+
101
+ // Only restore body overflow if this is the last open modal
102
+ const activeModals = document.querySelectorAll('[data-modal-active="true"]')
103
+ if (activeModals.length <= 1) {
104
+ document.body.style.overflow = '' // Restore scrolling
105
+ }
106
+
76
107
  document.removeEventListener('keydown', handleKeyDown)
77
108
  if (previouslyFocusedElement) {
78
109
  previouslyFocusedElement.focus()
@@ -111,7 +142,14 @@ onMounted(() => {
111
142
  onUnmounted(() => {
112
143
  eventBus.off(`${props.id}Modal`, setModal)
113
144
  document.removeEventListener('keydown', handleKeyDown)
114
- document.body.style.overflow = '' // Ensure body scrolling is restored
145
+
146
+ // Only restore body overflow if this modal was open when unmounted
147
+ if (isOpen.value) {
148
+ const activeModals = document.querySelectorAll('[data-modal-active="true"]')
149
+ if (activeModals.length <= 1) {
150
+ document.body.style.overflow = '' // Restore scrolling
151
+ }
152
+ }
115
153
  })
116
154
 
117
155
  // Click outside to close
@@ -135,22 +173,23 @@ function handleBackdropClick(event: MouseEvent) {
135
173
  <div
136
174
  v-if="isOpen"
137
175
  class="fixed inset-0 overflow-y-auto"
138
- style="z-index: 40"
176
+ :style="{ zIndex }"
139
177
  role="dialog"
140
178
  :aria-labelledby="title ? `${props.id}-title` : undefined"
141
179
  aria-modal="true"
180
+ data-modal-active="true"
142
181
  >
143
182
  <!-- Backdrop with click to close functionality -->
144
183
  <div
145
184
  class="flex absolute backdrop-blur-[8px] inset-0 flex-col items-center justify-center min-h-screen text-fv-neutral-800 dark:text-fv-neutral-300 bg-fv-neutral-900/[.20] dark:bg-fv-neutral-50/[.20]"
146
- style="z-index: 41"
185
+ :style="{ zIndex: zIndex + 1 }"
147
186
  @click="handleBackdropClick"
148
187
  >
149
188
  <!-- Modal panel -->
150
189
  <div
151
190
  ref="modalRef"
152
191
  :class="`relative ${mSize} max-w-6xl max-h-full ${ofy} bg-white rounded-lg shadow dark:bg-fv-neutral-900`"
153
- style="z-index: 42"
192
+ :style="{ zIndex: zIndex + 2 }"
154
193
  tabindex="-1"
155
194
  @click.stop
156
195
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "2.2.46",
3
+ "version": "2.2.48",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",