@fy-/fws-vue 2.2.45 → 2.2.47

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,13 +1,11 @@
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
 
6
+ // Static counter for managing z-index across all modals
7
+ let modalCounter = 0
8
+
11
9
  const props = withDefaults(
12
10
  defineProps<{
13
11
  id: string
@@ -30,14 +28,83 @@ const eventBus = useEventBus()
30
28
  const isOpen = ref<boolean>(false)
31
29
  const modalRef = ref<HTMLElement | null>(null)
32
30
  let previouslyFocusedElement: HTMLElement | null = null
31
+ let focusableElements: HTMLElement[] = []
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
+
37
+ // Trap focus within modal for accessibility
38
+ function getFocusableElements(element: HTMLElement): HTMLElement[] {
39
+ return Array.from(
40
+ element.querySelectorAll(
41
+ 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])',
42
+ ),
43
+ ).filter(
44
+ el => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'),
45
+ ) as HTMLElement[]
46
+ }
47
+
48
+ function handleKeyDown(event: KeyboardEvent) {
49
+ // Only handle events for the top-most modal
50
+ if (!isOpen.value) return
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
+
59
+ // Close on escape
60
+ if (event.key === 'Escape') {
61
+ event.preventDefault()
62
+ setModal(false)
63
+ return
64
+ }
65
+
66
+ // Handle tab trapping
67
+ if (event.key === 'Tab' && focusableElements.length > 0) {
68
+ // If shift + tab on first element, focus last element
69
+ if (event.shiftKey && document.activeElement === focusableElements[0]) {
70
+ event.preventDefault()
71
+ focusableElements[focusableElements.length - 1].focus()
72
+ }
73
+ // If tab on last element, focus first element
74
+ else if (!event.shiftKey && document.activeElement === focusableElements[focusableElements.length - 1]) {
75
+ event.preventDefault()
76
+ focusableElements[0].focus()
77
+ }
78
+ }
79
+ }
33
80
 
34
81
  function setModal(value: boolean) {
35
82
  if (value === true) {
36
83
  if (props.onOpen) props.onOpen()
37
84
  previouslyFocusedElement = document.activeElement as HTMLElement
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
+
96
+ document.addEventListener('keydown', handleKeyDown)
38
97
  }
39
98
  if (value === false) {
40
99
  if (props.onClose) props.onClose()
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
+
107
+ document.removeEventListener('keydown', handleKeyDown)
41
108
  if (previouslyFocusedElement) {
42
109
  previouslyFocusedElement.focus()
43
110
  }
@@ -45,54 +112,88 @@ function setModal(value: boolean) {
45
112
  isOpen.value = value
46
113
  }
47
114
 
115
+ // After modal is opened, set focus and collect focusable elements
116
+ watch(isOpen, async (newVal) => {
117
+ if (newVal) {
118
+ await nextTick()
119
+ if (modalRef.value) {
120
+ focusableElements = getFocusableElements(modalRef.value)
121
+
122
+ // Focus the first focusable element or the close button if available
123
+ const closeButton = modalRef.value.querySelector('button[aria-label="Close modal"]') as HTMLElement
124
+ if (closeButton) {
125
+ closeButton.focus()
126
+ }
127
+ else if (focusableElements.length > 0) {
128
+ focusableElements[0].focus()
129
+ }
130
+ else {
131
+ // If no focusable elements, focus the modal itself
132
+ modalRef.value.focus()
133
+ }
134
+ }
135
+ }
136
+ })
137
+
48
138
  onMounted(() => {
49
139
  eventBus.on(`${props.id}Modal`, setModal)
50
140
  })
51
141
 
52
142
  onUnmounted(() => {
53
143
  eventBus.off(`${props.id}Modal`, setModal)
54
- })
144
+ document.removeEventListener('keydown', handleKeyDown)
55
145
 
56
- /*
57
- watch(isOpen, async (newVal) => {
58
- if (newVal) {
59
- await nextTick()
60
- modalRef.value?.focus()
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
+ }
61
152
  }
62
153
  })
63
- */
154
+
155
+ // Click outside to close
156
+ function handleBackdropClick(event: MouseEvent) {
157
+ // Close only if clicking the backdrop, not the modal content
158
+ if (event.target === event.currentTarget) {
159
+ setModal(false)
160
+ }
161
+ }
64
162
  </script>
65
163
 
66
164
  <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"
165
+ <transition
166
+ enter-active-class="duration-300 ease-out"
167
+ enter-from-class="opacity-0"
168
+ enter-to-class="opacity-100"
169
+ leave-active-class="duration-200 ease-in"
170
+ leave-from-class="opacity-100"
171
+ leave-to-class="opacity-0"
76
172
  >
77
- <Dialog
78
- :open="isOpen"
173
+ <div
174
+ v-if="isOpen"
79
175
  class="fixed inset-0 overflow-y-auto"
80
- style="z-index: 40"
81
- aria-modal="true"
176
+ :style="{ zIndex }"
82
177
  role="dialog"
83
178
  :aria-labelledby="title ? `${props.id}-title` : undefined"
84
- @close="setModal"
179
+ aria-modal="true"
180
+ data-modal-active="true"
85
181
  >
86
- <DialogPanel
87
- ref="modalRef"
88
- tabindex="-1"
182
+ <!-- Backdrop with click to close functionality -->
183
+ <div
89
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]"
90
- style="z-index: 41"
185
+ :style="{ zIndex: zIndex + 1 }"
186
+ @click="handleBackdropClick"
91
187
  >
188
+ <!-- Modal panel -->
92
189
  <div
190
+ ref="modalRef"
93
191
  :class="`relative ${mSize} max-w-6xl max-h-full ${ofy} bg-white rounded-lg shadow dark:bg-fv-neutral-900`"
94
- style="z-index: 42"
192
+ :style="{ zIndex: zIndex + 2 }"
193
+ tabindex="-1"
194
+ @click.stop
95
195
  >
196
+ <!-- Header with title if provided -->
96
197
  <div
97
198
  v-if="title"
98
199
  class="flex items-center justify-between p-2 w-full border-b rounded-t dark:border-fv-neutral-700"
@@ -112,11 +213,12 @@ watch(isOpen, async (newVal) => {
112
213
  <component :is="closeIcon" class="w-7 h-7" />
113
214
  </button>
114
215
  </div>
216
+ <!-- Content area -->
115
217
  <div class="p-3 space-y-3">
116
218
  <slot />
117
219
  </div>
118
220
  </div>
119
- </DialogPanel>
120
- </Dialog>
121
- </TransitionRoot>
221
+ </div>
222
+ </div>
223
+ </transition>
122
224
  </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.47",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",