@fy-/fws-vue 2.2.45 → 2.2.46

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.
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { onMounted, onUnmounted } from 'vue'
3
-
2
+ import { onMounted, onUnmounted, ref } from 'vue'
4
3
  import ScaleTransition from './transitions/ScaleTransition.vue'
5
4
 
6
5
  const props = defineProps<{
@@ -17,6 +16,17 @@ const props = defineProps<{
17
16
  closeDropdown: () => void
18
17
  }>()
19
18
 
19
+ const dropdownRef = ref<HTMLElement | null>(null)
20
+
21
+ // Custom implementation of click-outside functionality
22
+ function handleClickOutsideElement(event: MouseEvent) {
23
+ if (props.preventClickOutside) return
24
+
25
+ if (props.show && dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
26
+ props.handleClickOutside()
27
+ }
28
+ }
29
+
20
30
  function handleCloseOnEscape(event: KeyboardEvent) {
21
31
  if (['Escape', 'Esc'].includes(event.key)) {
22
32
  props.closeDropdown()
@@ -25,10 +35,12 @@ function handleCloseOnEscape(event: KeyboardEvent) {
25
35
 
26
36
  onMounted(() => {
27
37
  document.addEventListener('keydown', handleCloseOnEscape)
38
+ document.addEventListener('click', handleClickOutsideElement)
28
39
  })
29
40
 
30
41
  onUnmounted(() => {
31
42
  document.removeEventListener('keydown', handleCloseOnEscape)
43
+ document.removeEventListener('click', handleClickOutsideElement)
32
44
  })
33
45
  </script>
34
46
 
@@ -42,7 +54,7 @@ onUnmounted(() => {
42
54
  <ScaleTransition>
43
55
  <div
44
56
  v-show="props.show"
45
- v-click-outside="props.handleClickOutside"
57
+ ref="dropdownRef"
46
58
  :class="props.position"
47
59
  :style="props.coordinates"
48
60
  class="absolute z-[100] w-[200px] mt-2 rounded-sm bg-white dark:bg-fv-neutral-900 shadow-lg border border-fv-neutral-100 dark:border-fv-neutral-600 focus:outline-none"
@@ -1,7 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { Component } from 'vue'
3
3
  import type { APIPaging } from '../../composables/rest'
4
- import { Dialog, DialogPanel, TransitionRoot } from '@headlessui/vue'
5
4
  import {
6
5
  ArrowLeftCircleIcon,
7
6
  ArrowRightCircleIcon,
@@ -69,9 +68,19 @@ const direction = ref<'next' | 'prev'>('next')
69
68
  function setModal(value: boolean) {
70
69
  if (value === true) {
71
70
  if (props.onOpen) props.onOpen()
71
+ document.body.style.overflow = 'hidden' // Prevent scrolling when gallery is open
72
+ if (!import.meta.env.SSR) {
73
+ document.addEventListener('keydown', handleKeyboardInput)
74
+ document.addEventListener('keyup', handleKeyboardRelease)
75
+ }
72
76
  }
73
77
  else {
74
78
  if (props.onClose) props.onClose()
79
+ document.body.style.overflow = '' // Restore scrolling
80
+ if (!import.meta.env.SSR) {
81
+ document.removeEventListener('keydown', handleKeyboardInput)
82
+ document.removeEventListener('keyup', handleKeyboardRelease)
83
+ }
75
84
  }
76
85
  isGalleryOpen.value = value
77
86
  }
@@ -164,8 +173,14 @@ function getBorderColor(i: any) {
164
173
  const isKeyPressed = ref<boolean>(false)
165
174
 
166
175
  function handleKeyboardInput(event: KeyboardEvent) {
176
+ if (!isGalleryOpen.value) return
167
177
  if (isKeyPressed.value) return
178
+
168
179
  switch (event.key) {
180
+ case 'Escape':
181
+ event.preventDefault()
182
+ setModal(false)
183
+ break
169
184
  case 'ArrowRight':
170
185
  isKeyPressed.value = true
171
186
  direction.value = 'next'
@@ -191,54 +206,60 @@ function closeGallery() {
191
206
  setModal(false)
192
207
  }
193
208
 
209
+ // Click outside gallery content to close
210
+ function handleBackdropClick(event: MouseEvent) {
211
+ if (event.target === event.currentTarget) {
212
+ setModal(false)
213
+ }
214
+ }
215
+
194
216
  onMounted(() => {
195
217
  eventBus.on(`${props.id}GalleryImage`, openGalleryImage)
196
218
  eventBus.on(`${props.id}Gallery`, openGalleryImage)
197
219
  eventBus.on(`${props.id}GalleryClose`, closeGallery)
198
- if (window !== undefined && !import.meta.env.SSR) {
199
- window.addEventListener('keydown', handleKeyboardInput)
200
- window.addEventListener('keyup', handleKeyboardRelease)
201
- }
202
220
  })
203
221
 
204
222
  onUnmounted(() => {
205
223
  eventBus.off(`${props.id}Gallery`, openGalleryImage)
206
224
  eventBus.off(`${props.id}GalleryImage`, openGalleryImage)
207
225
  eventBus.off(`${props.id}GalleryClose`, closeGallery)
208
- if (window !== undefined && !import.meta.env.SSR) {
209
- window.removeEventListener('keydown', handleKeyboardInput)
210
- window.removeEventListener('keyup', handleKeyboardRelease)
226
+ if (!import.meta.env.SSR) {
227
+ document.removeEventListener('keydown', handleKeyboardInput)
228
+ document.removeEventListener('keyup', handleKeyboardRelease)
229
+ document.body.style.overflow = '' // Ensure body scrolling is restored
211
230
  }
212
231
  })
213
232
  </script>
214
233
 
215
234
  <template>
216
235
  <div>
217
- <TransitionRoot
218
- :show="isGalleryOpen"
219
- as="template"
220
- enter="duration-300 ease-out"
221
- enter-from="opacity-0"
222
- enter-to="opacity-100"
223
- leave="duration-200 ease-in"
224
- leave-from="opacity-100"
225
- leave-to="opacity-0"
236
+ <transition
237
+ enter-active-class="duration-300 ease-out"
238
+ enter-from-class="opacity-0"
239
+ enter-to-class="opacity-100"
240
+ leave-active-class="duration-200 ease-in"
241
+ leave-from-class="opacity-100"
242
+ leave-to-class="opacity-0"
226
243
  >
227
- <Dialog
228
- :open="isGalleryOpen"
244
+ <div
245
+ v-if="isGalleryOpen"
229
246
  class="fixed bg-fv-neutral-900 text-white inset-0 max-w-[100vw] overflow-y-auto overflow-x-hidden"
230
247
  style="z-index: 37"
231
- @close="setModal"
248
+ role="dialog"
249
+ aria-modal="true"
250
+ @click="handleBackdropClick"
232
251
  >
233
- <DialogPanel
252
+ <div
234
253
  class="relative w-full max-w-full flex flex-col justify-center items-center"
235
254
  style="z-index: 38"
255
+ @click.stop
236
256
  >
237
257
  <div class="flex flex-grow gap-4 w-full max-w-full">
238
258
  <div class="flex-grow h-[100vh] flex items-center relative">
239
259
  <button
240
260
  class="btn w-9 h-9 rounded-full absolute top-4 left-2"
241
261
  style="z-index: 39"
262
+ aria-label="Close gallery"
242
263
  @click="setModal(false)"
243
264
  >
244
265
  <component :is="closeIcon" class="w-8 h-8" />
@@ -253,6 +274,7 @@ onUnmounted(() => {
253
274
  <button
254
275
  v-if="images.length > 1"
255
276
  class="btn p-1 rounded-full"
277
+ aria-label="Previous image"
256
278
  @click="goPrevImage()"
257
279
  >
258
280
  <ArrowLeftCircleIcon class="w-8 h-8" />
@@ -291,6 +313,7 @@ onUnmounted(() => {
291
313
  v-if="modelValueSrc && imageComponent === 'img'"
292
314
  class="shadow max-w-full h-auto object-contain max-h-[85vh]"
293
315
  :src="modelValueSrc"
316
+ :alt="`Gallery image ${modelValue + 1}`"
294
317
  >
295
318
  <component
296
319
  :is="imageComponent"
@@ -320,6 +343,7 @@ onUnmounted(() => {
320
343
  'right-2': !sidePanel,
321
344
  }"
322
345
  style="z-index: 39"
346
+ :aria-label="sidePanel ? 'Hide thumbnails' : 'Show thumbnails'"
323
347
  @click="() => (sidePanel = !sidePanel)"
324
348
  >
325
349
  <ChevronDoubleRightIcon v-if="sidePanel" class="w-7 h-7" />
@@ -328,6 +352,7 @@ onUnmounted(() => {
328
352
  <button
329
353
  v-if="images.length > 1"
330
354
  class="btn p-1 rounded-full"
355
+ aria-label="Next image"
331
356
  @click="goNextImage()"
332
357
  >
333
358
  <ArrowRightCircleIcon class="w-8 h-8" />
@@ -336,57 +361,60 @@ onUnmounted(() => {
336
361
  </div>
337
362
  </div>
338
363
 
339
- <TransitionRoot
340
- :show="sidePanel"
341
- as="div"
342
- enter="transform transition ease-in-out duration-300"
343
- enter-from="translate-x-full"
344
- enter-to="translate-x-0"
345
- leave="transform transition ease-in-out duration-300"
346
- leave-from="translate-x-0"
347
- leave-to="translate-x-full"
348
- class="hidden lg:block flex-shrink-0 w-64 bg-fv-neutral-800 h-[100vh] max-h-[100vh] overflow-y-auto"
364
+ <transition
365
+ enter-active-class="transform transition ease-in-out duration-300"
366
+ enter-from-class="translate-x-full"
367
+ enter-to-class="translate-x-0"
368
+ leave-active-class="transform transition ease-in-out duration-300"
369
+ leave-from-class="translate-x-0"
370
+ leave-to-class="translate-x-full"
349
371
  >
350
- <!-- Side panel content -->
351
- <div v-if="paging" class="flex items-center justify-center">
352
- <DefaultPaging :id="id" :items="paging" />
353
- </div>
354
- <div class="grid grid-cols-2 gap-2 p-2">
355
- <div
356
- v-for="i in images.length"
357
- :key="`bg_${id}_${i}`"
358
- class="hover:!brightness-100"
359
- :style="{
360
- filter:
361
- i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.5)',
362
- }"
363
- >
364
- <img
365
- v-if="imageComponent === 'img'"
366
- :class="`h-auto max-w-full rounded-lg cursor-pointer shadow ${getBorderColor(
367
- images[i - 1],
368
- )}`"
369
- :src="getThumbnailUrl(images[i - 1])"
370
- @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
372
+ <div
373
+ 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"
375
+ >
376
+ <!-- Side panel content -->
377
+ <div v-if="paging" class="flex items-center justify-center">
378
+ <DefaultPaging :id="id" :items="paging" />
379
+ </div>
380
+ <div class="grid grid-cols-2 gap-2 p-2">
381
+ <div
382
+ v-for="i in images.length"
383
+ :key="`bg_${id}_${i}`"
384
+ class="hover:!brightness-100"
385
+ :style="{
386
+ filter:
387
+ i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.5)',
388
+ }"
371
389
  >
372
- <component
373
- :is="imageComponent"
374
- v-else
375
- :image="getThumbnailUrl(images[i - 1]).image"
376
- :variant="getThumbnailUrl(images[i - 1]).variant"
377
- :alt="getThumbnailUrl(images[i - 1]).alt"
378
- :class="`h-auto max-w-full rounded-lg cursor-pointer shadow ${getBorderColor(
379
- images[i - 1],
380
- )}`"
381
- @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
382
- />
390
+ <img
391
+ v-if="imageComponent === 'img'"
392
+ :class="`h-auto max-w-full rounded-lg cursor-pointer shadow ${getBorderColor(
393
+ images[i - 1],
394
+ )}`"
395
+ :src="getThumbnailUrl(images[i - 1])"
396
+ :alt="`Thumbnail ${i}`"
397
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
398
+ >
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
+ </div>
383
411
  </div>
384
412
  </div>
385
- </TransitionRoot>
413
+ </transition>
386
414
  </div>
387
- </DialogPanel>
388
- </Dialog>
389
- </TransitionRoot>
415
+ </div>
416
+ </div>
417
+ </transition>
390
418
 
391
419
  <div v-if="mode === 'grid' || mode === 'mason' || mode === 'custom'" class="min-h-[600px]">
392
420
  <div
@@ -415,6 +443,7 @@ onUnmounted(() => {
415
443
  v-if="i + j - 2 < images.length && imageComponent === 'img'"
416
444
  class="h-auto max-w-full rounded-lg cursor-pointer"
417
445
  :src="getThumbnailUrl(images[i + j - 2])"
446
+ :alt="`Gallery image ${i + j - 1}`"
418
447
  @click="$eventBus.emit(`${id}GalleryImage`, i + j - 2)"
419
448
  >
420
449
  <component
@@ -444,6 +473,7 @@ onUnmounted(() => {
444
473
  v-if="imageComponent === 'img'"
445
474
  class="h-auto max-w-full rounded-lg cursor-pointer"
446
475
  :src="getThumbnailUrl(images[i - 1])"
476
+ :alt="`Gallery image ${i}`"
447
477
  @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
448
478
  >
449
479
  <component
@@ -1,11 +1,6 @@
1
1
  <script setup lang="ts">
2
- import {
3
- Dialog,
4
- DialogPanel,
5
- TransitionRoot,
6
- } from '@headlessui/vue'
7
2
  import { XCircleIcon } from '@heroicons/vue/24/solid'
8
- import { h, onMounted, onUnmounted, ref } from 'vue'
3
+ import { h, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
9
4
  import { useEventBus } from '../../composables/event-bus'
10
5
 
11
6
  const props = withDefaults(
@@ -30,14 +25,55 @@ const eventBus = useEventBus()
30
25
  const isOpen = ref<boolean>(false)
31
26
  const modalRef = ref<HTMLElement | null>(null)
32
27
  let previouslyFocusedElement: HTMLElement | null = null
28
+ let focusableElements: HTMLElement[] = []
29
+
30
+ // Trap focus within modal for accessibility
31
+ function getFocusableElements(element: HTMLElement): HTMLElement[] {
32
+ return Array.from(
33
+ element.querySelectorAll(
34
+ 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])',
35
+ ),
36
+ ).filter(
37
+ el => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'),
38
+ ) as HTMLElement[]
39
+ }
40
+
41
+ function handleKeyDown(event: KeyboardEvent) {
42
+ if (!isOpen.value) return
43
+
44
+ // Close on escape
45
+ if (event.key === 'Escape') {
46
+ event.preventDefault()
47
+ setModal(false)
48
+ return
49
+ }
50
+
51
+ // Handle tab trapping
52
+ if (event.key === 'Tab' && focusableElements.length > 0) {
53
+ // If shift + tab on first element, focus last element
54
+ if (event.shiftKey && document.activeElement === focusableElements[0]) {
55
+ event.preventDefault()
56
+ focusableElements[focusableElements.length - 1].focus()
57
+ }
58
+ // If tab on last element, focus first element
59
+ else if (!event.shiftKey && document.activeElement === focusableElements[focusableElements.length - 1]) {
60
+ event.preventDefault()
61
+ focusableElements[0].focus()
62
+ }
63
+ }
64
+ }
33
65
 
34
66
  function setModal(value: boolean) {
35
67
  if (value === true) {
36
68
  if (props.onOpen) props.onOpen()
37
69
  previouslyFocusedElement = document.activeElement as HTMLElement
70
+ document.body.style.overflow = 'hidden' // Prevent scrolling when modal is open
71
+ document.addEventListener('keydown', handleKeyDown)
38
72
  }
39
73
  if (value === false) {
40
74
  if (props.onClose) props.onClose()
75
+ document.body.style.overflow = '' // Restore scrolling
76
+ document.removeEventListener('keydown', handleKeyDown)
41
77
  if (previouslyFocusedElement) {
42
78
  previouslyFocusedElement.focus()
43
79
  }
@@ -45,54 +81,80 @@ function setModal(value: boolean) {
45
81
  isOpen.value = value
46
82
  }
47
83
 
84
+ // After modal is opened, set focus and collect focusable elements
85
+ watch(isOpen, async (newVal) => {
86
+ if (newVal) {
87
+ await nextTick()
88
+ if (modalRef.value) {
89
+ focusableElements = getFocusableElements(modalRef.value)
90
+
91
+ // Focus the first focusable element or the close button if available
92
+ const closeButton = modalRef.value.querySelector('button[aria-label="Close modal"]') as HTMLElement
93
+ if (closeButton) {
94
+ closeButton.focus()
95
+ }
96
+ else if (focusableElements.length > 0) {
97
+ focusableElements[0].focus()
98
+ }
99
+ else {
100
+ // If no focusable elements, focus the modal itself
101
+ modalRef.value.focus()
102
+ }
103
+ }
104
+ }
105
+ })
106
+
48
107
  onMounted(() => {
49
108
  eventBus.on(`${props.id}Modal`, setModal)
50
109
  })
51
110
 
52
111
  onUnmounted(() => {
53
112
  eventBus.off(`${props.id}Modal`, setModal)
113
+ document.removeEventListener('keydown', handleKeyDown)
114
+ document.body.style.overflow = '' // Ensure body scrolling is restored
54
115
  })
55
116
 
56
- /*
57
- watch(isOpen, async (newVal) => {
58
- if (newVal) {
59
- await nextTick()
60
- modalRef.value?.focus()
117
+ // Click outside to close
118
+ function handleBackdropClick(event: MouseEvent) {
119
+ // Close only if clicking the backdrop, not the modal content
120
+ if (event.target === event.currentTarget) {
121
+ setModal(false)
61
122
  }
62
- })
63
- */
123
+ }
64
124
  </script>
65
125
 
66
126
  <template>
67
- <TransitionRoot
68
- :show="isOpen"
69
- as="template"
70
- enter="duration-300 ease-out"
71
- enter-from="opacity-0"
72
- enter-to="opacity-100"
73
- leave="duration-200 ease-in"
74
- leave-from="opacity-100"
75
- leave-to="opacity-0"
127
+ <transition
128
+ enter-active-class="duration-300 ease-out"
129
+ enter-from-class="opacity-0"
130
+ enter-to-class="opacity-100"
131
+ leave-active-class="duration-200 ease-in"
132
+ leave-from-class="opacity-100"
133
+ leave-to-class="opacity-0"
76
134
  >
77
- <Dialog
78
- :open="isOpen"
135
+ <div
136
+ v-if="isOpen"
79
137
  class="fixed inset-0 overflow-y-auto"
80
138
  style="z-index: 40"
81
- aria-modal="true"
82
139
  role="dialog"
83
140
  :aria-labelledby="title ? `${props.id}-title` : undefined"
84
- @close="setModal"
141
+ aria-modal="true"
85
142
  >
86
- <DialogPanel
87
- ref="modalRef"
88
- tabindex="-1"
143
+ <!-- Backdrop with click to close functionality -->
144
+ <div
89
145
  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]"
90
146
  style="z-index: 41"
147
+ @click="handleBackdropClick"
91
148
  >
149
+ <!-- Modal panel -->
92
150
  <div
151
+ ref="modalRef"
93
152
  :class="`relative ${mSize} max-w-6xl max-h-full ${ofy} bg-white rounded-lg shadow dark:bg-fv-neutral-900`"
94
153
  style="z-index: 42"
154
+ tabindex="-1"
155
+ @click.stop
95
156
  >
157
+ <!-- Header with title if provided -->
96
158
  <div
97
159
  v-if="title"
98
160
  class="flex items-center justify-between p-2 w-full border-b rounded-t dark:border-fv-neutral-700"
@@ -112,11 +174,12 @@ watch(isOpen, async (newVal) => {
112
174
  <component :is="closeIcon" class="w-7 h-7" />
113
175
  </button>
114
176
  </div>
177
+ <!-- Content area -->
115
178
  <div class="p-3 space-y-3">
116
179
  <slot />
117
180
  </div>
118
181
  </div>
119
- </DialogPanel>
120
- </Dialog>
121
- </TransitionRoot>
182
+ </div>
183
+ </div>
184
+ </transition>
122
185
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "2.2.45",
3
+ "version": "2.2.46",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",